diff --git a/README.md b/README.md
index c29fd78..0a3595e 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Captail is designed around one rule: **Instant Replay should stay on.** A game c
## Interface
-
+
@@ -24,16 +24,21 @@ Captail is designed around one rule: **Instant Replay should stay on.** A game c
+
+
+
+
> [!WARNING]
> Captail `v0.1.x` is an early public preview. Core recording works, but bugs and hardware-specific problems are expected. Please report anything that does not work.
-## What's new in v0.1.3
+## What's new in v0.1.4
-- **Built-in updates** — the footer shows update status and installs the correct Installer or Portable package after verifying its SHA-256 digest.
-- **Clearer settings** — concise help popups explain codecs, bitrate, resolution, frame rate, audio tracks, buffer limits, and other technical options in English and Russian.
-- **Non-overlapping saves** — after saving a replay, the next clip starts at that save boundary instead of repeating footage from the previous file.
-- **More accurate save duration** — the main action shows how much new footage is currently available.
-- **Safer recovery** — the watchdog no longer treats normal recording-pipeline startup as a failure.
+- **Replay library** — every saved replay is available from the main window in a fast, scrollable list with thumbnail previews.
+- **Cleaner replay actions** — hover a clip for compact Open in folder, Trim, and Delete controls. Deletion always requires confirmation.
+- **Built-in clip editor** — scrub through video, select one trim range, preview both audio tracks, keep or remove each track, then save a copy or overwrite the original.
+- **Useful clip details** — the editor shows estimated trimmed size, current file size, resolution, true source FPS, and codec while the range changes.
+- **More reliable preview** — full-frame DPI-aware playback replaces the cropped upper-left preview and includes system/game plus microphone audio.
+- **Automatic game capture** — Desktop mode records the selected monitor and switches to direct Game Capture for a detected fullscreen game. Game Capture mode waits for any game without recording the desktop.
## Why Captail?
@@ -77,7 +82,7 @@ Captail checks GitHub Releases in the background. When an update is available, t
1. Install Captail or extract the Portable ZIP.
2. Launch `Captail.exe`.
-3. Select **Desktop** or a running game under **Source**.
+3. Select **Desktop** for automatic desktop/game switching, or **Game Capture** to record games only.
4. Choose buffer length, codec, resolution, FPS, and audio sources.
5. Keep Instant Replay enabled.
6. Press `Ctrl+Shift+F10` to save the current buffer.
@@ -125,6 +130,15 @@ Both hotkeys can be changed in Settings.
- Direct repository link in the footer.
- English interface by default, with live English/Russian switching.
+### Replay library and editor
+
+- Scrollable library containing every supported replay in the selected folder.
+- Cached thumbnail strip and responsive seeking.
+- One visual trim range with draggable start and end handles.
+- Independent system/game and microphone track selection.
+- Save as a new clip or overwrite the original after confirmation.
+- Live estimated output size, source size, resolution, FPS, and codec.
+
### Reliability
- Recording-pipeline watchdog.
@@ -187,7 +201,7 @@ dotnet build .\Captail.sln -c Release
Create local release packages:
```powershell
-.\tools\BuildRelease.ps1 -Version 0.1.0 -InnoSetupCompiler "C:\Program Files\Inno Setup 7\ISCC.exe"
+.\tools\BuildRelease.ps1 -Version 0.1.4 -InnoSetupCompiler "C:\Program Files\Inno Setup 7\ISCC.exe"
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and [docs/RELEASING.md](docs/RELEASING.md) for the automated release process.
@@ -200,6 +214,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and [docs/RELE
- `native/ObsCaptureFixture` — D3D11 fixture for Game Capture and high-FPS validation.
- `App.xaml.cs` — tray, hotkeys, notifications, single-instance behavior, watchdog, and recovery.
- `SettingsWindow.*` — compact WPF interface.
+- `ReplayLibrary.cs` — replay discovery, metadata, thumbnail cache, delete, and trim orchestration.
+- `ClipEditorWindow.*` — preview, timeline, audio-track selection, and copy/overwrite workflows.
+- `FfmpegAdapter.cs` and `FfplayHost.cs` — bundled media probing, thumbnails, waveforms, trimming, and editor playback.
- `UpdateService.cs` — GitHub release discovery, package verification, Installer updates, and Portable self-replacement.
## License and attribution
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 9e6a323..0c088a8 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -11,6 +11,15 @@ Captail dynamically uses and may redistribute selected components from OBS Studi
`tools/AcquireObsRuntime.ps1` prepares the runtime. Distributions contain only components required for libobs, Replay Buffer, Windows capture, WASAPI audio, and supported hardware encoders.
+## FFmpeg
+
+- Project: https://ffmpeg.org/
+- Windows build: https://github.com/BtbN/FFmpeg-Builds
+- Build: `n7.1.5-12-g1fdbca85aa`, LGPL shared variant
+- License: GNU Lesser General Public License 2.1 or later; optional components retain their own licenses
+
+`tools/AcquireFfmpegRuntime.ps1` downloads a pinned, SHA-256-verified build. Captail uses it for clip metadata, thumbnails, and non-destructive trimming.
+
## NuGet dependencies
- NAudio — MIT License: https://github.com/naudio/NAudio
diff --git a/docs/captail-editor.png b/docs/captail-editor.png
new file mode 100644
index 0000000..d819099
Binary files /dev/null and b/docs/captail-editor.png differ
diff --git a/docs/captail-main.png b/docs/captail-main.png
new file mode 100644
index 0000000..d9c132f
Binary files /dev/null and b/docs/captail-main.png differ
diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs
index fd943cf..3667124 100644
--- a/src/Captail/App.xaml.cs
+++ b/src/Captail/App.xaml.cs
@@ -80,6 +80,19 @@ protected override async void OnStartup(StartupEventArgs e)
bool updateCheckTest = e.Args.Contains(
"--qa-update-check",
StringComparer.OrdinalIgnoreCase);
+ string? clipEditorTestPath = e.Args
+ .FirstOrDefault(argument => argument.StartsWith(
+ "--qa-clip-editor=",
+ StringComparison.OrdinalIgnoreCase))
+ ?["--qa-clip-editor=".Length..];
+ bool clipEditorTest = !string.IsNullOrWhiteSpace(clipEditorTestPath);
+ string? previewGeometryTestPath = e.Args
+ .FirstOrDefault(argument => argument.StartsWith(
+ "--qa-preview-geometry=",
+ StringComparison.OrdinalIgnoreCase))
+ ?["--qa-preview-geometry=".Length..];
+ bool previewGeometryTest =
+ !string.IsNullOrWhiteSpace(previewGeometryTestPath);
_qaUpdateAvailable = e.Args.Contains(
"--qa-update-available",
StringComparer.OrdinalIgnoreCase);
@@ -90,6 +103,8 @@ protected override async void OnStartup(StartupEventArgs e)
const bool gameCaptureTest = false;
const bool replaySegmentsTest = false;
const bool updateCheckTest = false;
+ const bool clipEditorTest = false;
+ const bool previewGeometryTest = false;
#endif
bool backgroundLaunch = e.Args.Contains(
"--background",
@@ -101,7 +116,8 @@ protected override async void OnStartup(StartupEventArgs e)
backgroundLaunch,
shutdownExisting,
_uiOnly || faultTest || codecTest || capabilityModelTest ||
- gameCaptureTest || replaySegmentsTest || updateCheckTest))
+ gameCaptureTest || replaySegmentsTest || updateCheckTest ||
+ clipEditorTest || previewGeometryTest))
{
Shutdown();
return;
@@ -163,6 +179,16 @@ await _updateService.CheckAsync(
Shutdown(0);
return;
}
+ if (clipEditorTest)
+ {
+ await RunClipEditorTestAsync(clipEditorTestPath!);
+ return;
+ }
+ if (previewGeometryTest)
+ {
+ await RunPreviewGeometryTestAsync(previewGeometryTestPath!);
+ return;
+ }
#endif
if (_uiOnly)
{
@@ -224,6 +250,74 @@ await TryStartPipelineAsync(showError: true))
}
#if DEBUG
+ private async Task RunClipEditorTestAsync(string path)
+ {
+ string fullPath = Path.GetFullPath(path);
+ if (!File.Exists(fullPath))
+ throw new FileNotFoundException("QA replay does not exist.", fullPath);
+
+ var ffmpeg = new FfmpegAdapter();
+ TimeSpan duration = await ffmpeg.ReadDurationAsync(fullPath);
+ var file = new FileInfo(fullPath);
+ var clip = new ReplayClip(
+ fullPath,
+ file.Name,
+ null,
+ file.LastWriteTime,
+ file.Length,
+ duration,
+ null);
+ var window = new ClipEditorWindow(
+ new ReplayLibrary(ffmpeg),
+ file.DirectoryName!,
+ clip,
+ saved => Log.Write($"CLIP_EDITOR_QA saved={saved}"));
+ MainWindow = window;
+ window.ShowDialog();
+ Shutdown(0);
+ }
+
+ private async Task RunPreviewGeometryTestAsync(string path)
+ {
+ string fullPath = Path.GetFullPath(path);
+ if (!File.Exists(fullPath))
+ throw new FileNotFoundException("QA replay does not exist.", fullPath);
+
+ var ffmpeg = new FfmpegAdapter();
+ TimeSpan duration = await ffmpeg.ReadDurationAsync(fullPath);
+ var file = new FileInfo(fullPath);
+ var clip = new ReplayClip(
+ fullPath,
+ file.Name,
+ null,
+ file.LastWriteTime,
+ file.Length,
+ duration,
+ null);
+ var window = new ClipEditorWindow(
+ new ReplayLibrary(ffmpeg),
+ file.DirectoryName!,
+ clip,
+ _ => { });
+ MainWindow = window;
+ window.Show();
+ try
+ {
+ (bool passed, string details) =
+ await window.RunPreviewGeometryQaAsync();
+ Log.Write(
+ $"PREVIEW_GEOMETRY_TEST {(passed ? "PASS" : "FAIL")}: {details}");
+ window.Close();
+ Shutdown(passed ? 0 : 15);
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"PREVIEW_GEOMETRY_TEST FAIL: {exception}");
+ window.Close();
+ Shutdown(15);
+ }
+ }
+
private void RunCapabilityModelTest()
{
try
@@ -363,7 +457,12 @@ private async void RunCodecTest(string[] args)
continue;
}
- await Task.Delay(TimeSpan.FromSeconds(6));
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ bool sourceChanged = _obs!.RefreshCaptureState();
+ Log.Write(
+ $"OBS_CODEC_TEST {codec}: source={_obs.Description}, " +
+ $"changed={sourceChanged}, game={_obs.ActiveGameExecutable}");
+ await Task.Delay(TimeSpan.FromSeconds(4));
string path = await _obs!.SaveReplayAsync();
bool saved = File.Exists(path) && new FileInfo(path).Length > 0;
allPassed &= saved;
@@ -387,11 +486,6 @@ private async void RunGameCaptureTest(string[] args)
{
try
{
- string gamePath = args
- .First(argument => argument.StartsWith(
- "--qa-game-capture=",
- StringComparison.OrdinalIgnoreCase))
- ["--qa-game-capture=".Length..];
string codec = args
.FirstOrDefault(argument => argument.StartsWith(
"--qa-game-codec=",
@@ -413,7 +507,6 @@ private async void RunGameCaptureTest(string[] args)
BitrateMbps = 50,
Codec = codec,
CaptureSource = "game",
- GameExecutablePath = gamePath,
CaptureSystemAudio = false,
CaptureMicrophone = false,
OutputDirectory = root,
@@ -958,16 +1051,18 @@ private async Task MonitorPipelineAsync()
recoveryReason = Localization.Text(
"L.Recovery.ModuleStopped");
}
- else if (DateTime.UtcNow - _pipelineStartedUtc <
- TimeSpan.FromSeconds(8))
- {
- return;
- }
else
{
+ bool checkHealth = DateTime.UtcNow - _pipelineStartedUtc >=
+ TimeSpan.FromSeconds(8);
(bool healthy, string? description) =
await RunOnObsThreadAsync(() =>
- (engine.IsHealthy, engine.Description));
+ {
+ engine.RefreshCaptureState();
+ return (
+ checkHealth ? engine.IsHealthy : true,
+ engine.Description);
+ });
if (healthy)
_captureDescription = description;
else
@@ -1649,6 +1744,7 @@ private async Task SaveReplayCoreAsync(ObsReplayEngine engine)
OverlayTone.Neutral,
30_000);
string path = await SaveReplayGuardedAsync(engine);
+ _settingsWindow?.NotifyReplaySaved(path);
ShowOverlayNotification(
"✓",
Localization.Text("L.Notify.Saved"),
@@ -1703,7 +1799,18 @@ await RunOnObsThreadAsync(engine.ResetReplayWindow)
await RunOnObsThreadAsync(engine.ResetReplayWindow)
.ConfigureAwait(false);
}
- return path;
+ try
+ {
+ return ReplayPaths.RouteSavedReplay(
+ _config!,
+ path,
+ operation.GameExecutable);
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Could not route replay into game folder: {exception}");
+ return path;
+ }
}
finally
{
diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj
index 03ef977..049f697 100644
--- a/src/Captail/Captail.csproj
+++ b/src/Captail/Captail.csproj
@@ -14,7 +14,7 @@
Captail
Captail
Assets\Captail.ico
- 0.1.3
+ 0.1.4
true
@@ -43,7 +43,9 @@
$(MSBuildProjectDirectory)\..\..\runtime\obs
+ $(MSBuildProjectDirectory)\..\..\runtime\ffmpeg
true
+ true
$(MSBuildProjectDirectory)\..\..\native\ObsBridge
$(ObsBridgeSource)\build
@@ -54,6 +56,12 @@
+
+
+
+
@@ -102,6 +110,12 @@
PreserveNewest
PreserveNewest
+
+ ffmpeg\%(RecursiveDir)%(Filename)%(Extension)
+ true
+ PreserveNewest
+ PreserveNewest
+
diff --git a/src/Captail/ClipEditorWindow.xaml b/src/Captail/ClipEditorWindow.xaml
new file mode 100644
index 0000000..2891295
--- /dev/null
+++ b/src/Captail/ClipEditorWindow.xaml
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Captail/ClipEditorWindow.xaml.cs b/src/Captail/ClipEditorWindow.xaml.cs
new file mode 100644
index 0000000..67d4564
--- /dev/null
+++ b/src/Captail/ClipEditorWindow.xaml.cs
@@ -0,0 +1,807 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+
+namespace Captail;
+
+public partial class ClipEditorWindow : Window
+{
+ private const double MinimumSelectionSeconds = 0.25;
+ private const int TimelineFrameCount = 12;
+ private readonly ReplayLibrary _library;
+ private readonly string _rootDirectory;
+ private readonly ReplayClip _clip;
+ private readonly Action _onSaved;
+ private readonly CancellationTokenSource _lifetimeCts = new();
+ private readonly DispatcherTimer _playbackTimer;
+ private readonly Stopwatch _playbackClock = new();
+ private readonly List _timelineImages = [];
+ private double _selectionStart;
+ private double _selectionEnd;
+ private double _playbackPosition;
+ private bool _playing;
+ private bool _playerLoading;
+ private bool _resumeAfterScrub;
+ private int _stillRequest;
+ private VideoStreamInfo? _videoInfo;
+
+ public ObservableCollection AudioTracks { get; } = [];
+
+ public ClipEditorWindow(
+ ReplayLibrary library,
+ string rootDirectory,
+ ReplayClip clip,
+ Action onSaved)
+ {
+ _library = library;
+ _rootDirectory = rootDirectory;
+ _clip = clip;
+ _onSaved = onSaved;
+ _selectionEnd = Math.Max(MinimumSelectionSeconds, clip.Duration.TotalSeconds);
+ InitializeComponent();
+ DataContext = this;
+ ClipNameText.Text = clip.Name;
+ if (clip.ThumbnailPath is not null && File.Exists(clip.ThumbnailPath))
+ PreviewImage.Source = LoadBitmap(clip.ThumbnailPath, 900);
+ UpdateRangeText();
+ UpdateTimelineVisual();
+
+ _playbackTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
+ _playbackTimer.Tick += async (_, _) => await UpdatePlaybackAsync();
+ Loaded += async (_, _) => await LoadEditorAsync();
+ SourceInitialized += (_, _) => ApplyNativeCornerPreference();
+ Closed += (_, _) =>
+ {
+ _playbackTimer.Stop();
+ _playbackClock.Stop();
+ _lifetimeCts.Cancel();
+ PreviewPlayer.Stop();
+ _lifetimeCts.Dispose();
+ };
+ }
+
+ private async Task LoadEditorAsync()
+ {
+ await Task.WhenAll(
+ LoadTimelineThumbnailsAsync(),
+ LoadAudioTracksAsync(),
+ LoadVideoInfoAsync(),
+ UpdateStillFrameAsync(_selectionStart));
+ if (!_lifetimeCts.IsCancellationRequested)
+ PlayButton.IsEnabled = true;
+ }
+
+ private async Task LoadVideoInfoAsync()
+ {
+ try
+ {
+ _videoInfo = await _library.GetVideoInfoAsync(
+ _rootDirectory,
+ _clip,
+ _lifetimeCts.Token);
+ UpdateClipInfoText();
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Video metadata inspection failed: {exception.Message}");
+ UpdateClipInfoText();
+ }
+ }
+
+#if DEBUG
+ internal async Task<(bool Passed, string Details)> RunPreviewGeometryQaAsync()
+ {
+ await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Loaded);
+ await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render);
+ await Task.Delay(200, _lifetimeCts.Token);
+ await StartPlaybackAsync(_selectionStart);
+ await Task.Delay(700, _lifetimeCts.Token);
+ string details = "preview window is not ready";
+ bool passed = PreviewPlayer.IsReady &&
+ PreviewPlayer.TryValidateGeometry(out details);
+ StopNativePlayback();
+ return (passed, details);
+ }
+#endif
+
+ private async Task LoadTimelineThumbnailsAsync()
+ {
+ try
+ {
+ IReadOnlyList paths = await _library.GetTimelineThumbnailsAsync(
+ _rootDirectory,
+ _clip,
+ TimelineFrameCount,
+ _lifetimeCts.Token);
+ _timelineImages.Clear();
+ _timelineImages.AddRange(paths.Select(path => LoadBitmap(path, 240)));
+ TimelineFrames.ItemsSource = _timelineImages;
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Timeline thumbnail generation failed: {exception.Message}");
+ }
+ }
+
+ private async Task LoadAudioTracksAsync()
+ {
+ try
+ {
+ IReadOnlyList tracks = await _library.GetAudioTracksAsync(
+ _rootDirectory,
+ _clip,
+ _lifetimeCts.Token);
+ AudioTracks.Clear();
+ foreach (AudioTrackInfo track in tracks)
+ {
+ AudioTracks.Add(new AudioTrackRow(
+ track,
+ AudioLabel(track, tracks.Count)));
+ }
+ NoAudioText.Visibility = tracks.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+
+ await Task.WhenAll(AudioTracks.Select(LoadWaveformAsync));
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Audio track inspection failed: {exception.Message}");
+ NoAudioText.Visibility = Visibility.Visible;
+ }
+ }
+
+ private async Task LoadWaveformAsync(AudioTrackRow row)
+ {
+ try
+ {
+ string? path = await _library.GetAudioWaveformAsync(
+ _rootDirectory,
+ _clip,
+ row.Track,
+ _lifetimeCts.Token);
+ if (path is not null && File.Exists(path) && !_lifetimeCts.IsCancellationRequested)
+ row.Waveform = LoadBitmap(path, 1200);
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Audio waveform generation failed: {exception.Message}");
+ }
+ }
+
+ private async Task StartPlaybackAsync(double position)
+ {
+ if (_playerLoading || _lifetimeCts.IsCancellationRequested)
+ return;
+ _playerLoading = true;
+ PlayButton.IsEnabled = false;
+ _playbackTimer.Stop();
+ _playbackClock.Reset();
+ _playing = false;
+ _playbackPosition = Math.Clamp(position, _selectionStart, _selectionEnd);
+ UpdatePlayIcon();
+ UpdatePlaybackText();
+ try
+ {
+ double remaining = Math.Max(
+ MinimumSelectionSeconds,
+ _selectionEnd - _playbackPosition);
+ PreviewPlayer.Visibility = Visibility.Visible;
+ await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render);
+ TimeSpan startupDelay = await PreviewPlayer.PlayAsync(
+ _clip.Path,
+ TimeSpan.FromSeconds(_playbackPosition),
+ TimeSpan.FromSeconds(remaining),
+ SelectedAudioStreamIndices(),
+ _lifetimeCts.Token);
+ _playbackPosition = Math.Min(
+ _selectionEnd,
+ _playbackPosition + startupDelay.TotalSeconds);
+ _playing = true;
+ _playbackClock.Restart();
+ _playbackTimer.Start();
+ EditorStatusText.Text = "";
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ PreviewPlayer.Visibility = Visibility.Collapsed;
+ Log.Write($"Clip preview failed ({_clip.Name}): {exception}");
+ EditorStatusText.Text = exception.Message;
+ }
+ finally
+ {
+ _playerLoading = false;
+ PlayButton.IsEnabled = !_lifetimeCts.IsCancellationRequested;
+ UpdatePlayIcon();
+ }
+ }
+
+ private async void PlayPause_Click(object sender, RoutedEventArgs e)
+ {
+ if (_playerLoading)
+ return;
+ if (_playing)
+ {
+ await PausePlaybackAsync();
+ return;
+ }
+ double start = CurrentPlaybackPosition() >= _selectionEnd - 0.05
+ ? _selectionStart
+ : CurrentPlaybackPosition();
+ await StartPlaybackAsync(start);
+ }
+
+ private async Task UpdatePlaybackAsync()
+ {
+ double position = CurrentPlaybackPosition();
+ if (position >= _selectionEnd || !PreviewPlayer.IsReady)
+ {
+ StopNativePlayback();
+ _playbackPosition = _selectionStart;
+ UpdatePlayIcon();
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ await UpdateStillFrameAsync(_playbackPosition);
+ return;
+ }
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ }
+
+ private double CurrentPlaybackPosition() =>
+ Math.Min(
+ _selectionEnd,
+ _playbackPosition + (_playing ? _playbackClock.Elapsed.TotalSeconds : 0));
+
+ private async Task PausePlaybackAsync(bool updateStill = true)
+ {
+ if (_playing)
+ _playbackPosition = CurrentPlaybackPosition();
+ StopNativePlayback();
+ UpdatePlayIcon();
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ if (updateStill)
+ await UpdateStillFrameAsync(_playbackPosition);
+ }
+
+ private void StopNativePlayback()
+ {
+ _playbackClock.Reset();
+ _playing = false;
+ _playbackTimer.Stop();
+ PreviewPlayer.Stop();
+ PreviewPlayer.Visibility = Visibility.Collapsed;
+ }
+
+ private async Task UpdateStillFrameAsync(double position)
+ {
+ int request = Interlocked.Increment(ref _stillRequest);
+ try
+ {
+ string? path = await _library.GetPreviewThumbnailAsync(
+ _rootDirectory,
+ _clip,
+ TimeSpan.FromSeconds(position),
+ _lifetimeCts.Token);
+ if (request == Volatile.Read(ref _stillRequest) &&
+ path is not null && File.Exists(path) &&
+ !_lifetimeCts.IsCancellationRequested)
+ {
+ PreviewImage.Source = LoadBitmap(path, 900);
+ }
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Preview still generation failed: {exception.Message}");
+ }
+ }
+
+ private void PauseForTimelineEdit()
+ {
+ if (_playing)
+ _playbackPosition = CurrentPlaybackPosition();
+ StopNativePlayback();
+ UpdatePlayIcon();
+ }
+
+ private void StartThumb_DragDelta(object sender, DragDeltaEventArgs e)
+ {
+ PauseForTimelineEdit();
+ _selectionStart = Math.Clamp(
+ _selectionStart + PixelsToSeconds(e.HorizontalChange),
+ 0,
+ _selectionEnd - MinimumSelectionSeconds);
+ _playbackPosition = _selectionStart;
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdateRangeText();
+ UpdateTimelineVisual();
+ }
+
+ private void EndThumb_DragDelta(object sender, DragDeltaEventArgs e)
+ {
+ PauseForTimelineEdit();
+ _selectionEnd = Math.Clamp(
+ _selectionEnd + PixelsToSeconds(e.HorizontalChange),
+ _selectionStart + MinimumSelectionSeconds,
+ Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds));
+ if (_playbackPosition > _selectionEnd)
+ _playbackPosition = _selectionEnd;
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdateRangeText();
+ UpdateTimelineVisual();
+ }
+
+ private async void RangeThumb_DragCompleted(object sender, DragCompletedEventArgs e)
+ {
+ _playbackPosition = sender == StartThumb ? _selectionStart : _selectionEnd;
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ await UpdateStillFrameAsync(_playbackPosition);
+ }
+
+ private void PlayheadThumb_DragStarted(object sender, DragStartedEventArgs e)
+ {
+ _resumeAfterScrub = _playing;
+ PauseForTimelineEdit();
+ }
+
+ private void PlayheadThumb_DragDelta(object sender, DragDeltaEventArgs e)
+ {
+ _playbackPosition = Math.Clamp(
+ _playbackPosition + PixelsToSeconds(e.HorizontalChange),
+ _selectionStart,
+ _selectionEnd);
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ }
+
+ private async void PlayheadThumb_DragCompleted(object sender, DragCompletedEventArgs e)
+ {
+ bool resume = _resumeAfterScrub;
+ _resumeAfterScrub = false;
+ if (resume)
+ await StartPlaybackAsync(_playbackPosition);
+ else
+ await UpdateStillFrameAsync(_playbackPosition);
+ }
+
+ private async void RangeTimeline_MouseLeftButtonDown(
+ object sender,
+ MouseButtonEventArgs e)
+ {
+ if (FindAncestor(e.OriginalSource as DependencyObject) is not null)
+ return;
+ bool resume = _playing;
+ PauseForTimelineEdit();
+ double fraction = Math.Clamp(
+ e.GetPosition(RangeTimeline).X / Math.Max(1, RangeTimeline.ActualWidth),
+ 0,
+ 1);
+ _playbackPosition = Math.Clamp(
+ fraction * Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds),
+ _selectionStart,
+ _selectionEnd);
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ if (resume)
+ await StartPlaybackAsync(_playbackPosition);
+ else
+ await UpdateStillFrameAsync(_playbackPosition);
+ e.Handled = true;
+ }
+
+ private async void Back_Click(object sender, RoutedEventArgs e) =>
+ await SeekAsync(CurrentPlaybackPosition() - 5);
+
+ private async void Forward_Click(object sender, RoutedEventArgs e) =>
+ await SeekAsync(CurrentPlaybackPosition() + 5);
+
+ private async Task SeekAsync(double position)
+ {
+ bool resume = _playing;
+ PauseForTimelineEdit();
+ _playbackPosition = Math.Clamp(position, _selectionStart, _selectionEnd);
+ ShowNearestTimelineFrame(_playbackPosition);
+ UpdatePlaybackText();
+ UpdateTimelineVisual();
+ if (resume)
+ await StartPlaybackAsync(_playbackPosition);
+ else
+ await UpdateStillFrameAsync(_playbackPosition);
+ }
+
+ private async void AudioTrackToggle_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_playing || _playerLoading)
+ return;
+ double position = CurrentPlaybackPosition();
+ PauseForTimelineEdit();
+ await StartPlaybackAsync(position);
+ }
+
+ private void RangeTimeline_SizeChanged(object sender, SizeChangedEventArgs e) =>
+ UpdateTimelineVisual();
+
+ private double PixelsToSeconds(double pixels) =>
+ pixels / Math.Max(1, RangeTimeline.ActualWidth) *
+ Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds);
+
+ private void UpdateTimelineVisual()
+ {
+ if (RangeTimeline is null || StartThumb is null || EndThumb is null)
+ return;
+ double duration = Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds);
+ double width = Math.Max(1, RangeTimeline.ActualWidth);
+ double handleWidth = StartThumb.Width;
+ double startEdge = _selectionStart / duration * width;
+ double endEdge = _selectionEnd / duration * width;
+
+ double handleLimit = Math.Max(0, width - handleWidth);
+ Canvas.SetLeft(StartThumb, Math.Clamp(startEdge - handleWidth / 2, 0, handleLimit));
+ Canvas.SetLeft(EndThumb, Math.Clamp(endEdge - handleWidth / 2, 0, handleLimit));
+ Canvas.SetLeft(LeftShade, 0);
+ LeftShade.Width = Math.Max(0, startEdge);
+ Canvas.SetLeft(RightShade, endEdge);
+ RightShade.Width = Math.Max(0, width - endEdge);
+ Canvas.SetLeft(SelectionBorder, startEdge);
+ SelectionBorder.Width = Math.Max(0, endEdge - startEdge);
+
+ double playhead = CurrentPlaybackPosition() / duration * width;
+ double playheadLimit = Math.Max(0, width - PlayheadThumb.Width);
+ Canvas.SetLeft(
+ PlayheadThumb,
+ Math.Clamp(playhead - PlayheadThumb.Width / 2, 0, playheadLimit));
+ }
+
+ private void ShowNearestTimelineFrame(double position)
+ {
+ if (_timelineImages.Count == 0)
+ return;
+ double duration = Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds);
+ int index = Math.Clamp(
+ (int)(position / duration * _timelineImages.Count),
+ 0,
+ _timelineImages.Count - 1);
+ PreviewImage.Source = _timelineImages[index];
+ }
+
+ private void UpdateRangeText()
+ {
+ if (StartTimeText is null || EndTimeText is null)
+ return;
+ StartTimeText.Text = FormatTime(TimeSpan.FromSeconds(_selectionStart), true);
+ EndTimeText.Text = FormatTime(TimeSpan.FromSeconds(_selectionEnd), true);
+ RangeDurationText.Text = Localization.Format(
+ "L.Library.RangeSummary",
+ FormatTime(TimeSpan.FromSeconds(_selectionEnd - _selectionStart), false));
+ UpdateClipInfoText();
+ UpdatePlaybackText();
+ }
+
+ private void UpdateClipInfoText()
+ {
+ if (ClipInfoText is null)
+ return;
+ double sourceSeconds = Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds);
+ double selectedSeconds = Math.Clamp(
+ _selectionEnd - _selectionStart,
+ MinimumSelectionSeconds,
+ sourceSeconds);
+ long estimatedBytes = Math.Max(
+ 1,
+ (long)Math.Round(_clip.SizeBytes * selectedSeconds / sourceSeconds));
+ string resolution = _videoInfo is { Width: > 0, Height: > 0 }
+ ? $"{_videoInfo.Width}×{_videoInfo.Height}"
+ : "—";
+ string frameRate = _videoInfo is { FrameRate: > 0 }
+ ? _videoInfo.FrameRate.ToString(
+ Math.Abs(_videoInfo.FrameRate - Math.Round(_videoInfo.FrameRate)) < 0.01
+ ? "0"
+ : "0.##",
+ System.Globalization.CultureInfo.InvariantCulture)
+ : "—";
+ string codec = FormatVideoCodec(_videoInfo?.Codec);
+ ClipInfoText.Text =
+ $"≈{FormatFileSize(estimatedBytes)} / {FormatFileSize(_clip.SizeBytes)} · " +
+ $"{resolution} · {frameRate} FPS · {codec}";
+ }
+
+ private static string FormatVideoCodec(string? codec) =>
+ codec?.ToLowerInvariant() switch
+ {
+ "av1" => "AV1",
+ "h264" => "H.264",
+ "hevc" or "h265" => "HEVC",
+ "vp9" => "VP9",
+ "vp8" => "VP8",
+ null or "" => "—",
+ _ => codec.ToUpperInvariant(),
+ };
+
+ private static string FormatFileSize(long bytes)
+ {
+ const double megabyte = 1024d * 1024;
+ const double gigabyte = 1024d * 1024 * 1024;
+ return bytes >= gigabyte
+ ? $"{bytes / gigabyte:0.##} GB"
+ : $"{bytes / megabyte:0.#} MB";
+ }
+
+ private void UpdatePlaybackText()
+ {
+ if (PlaybackTimeText is null)
+ return;
+ PlaybackTimeText.Text =
+ $"{FormatTime(TimeSpan.FromSeconds(CurrentPlaybackPosition()), false)} / " +
+ FormatTime(_clip.Duration, false);
+ }
+
+ private void UpdatePlayIcon()
+ {
+ if (PlayIcon is null)
+ return;
+ PlayIcon.Data = (Geometry)FindResource(_playing ? "IconPause" : "IconPlay");
+ }
+
+ private IReadOnlyList SelectedAudioStreamIndices() =>
+ AudioTracks
+ .Where(track => track.IsSelected)
+ .Select(track => track.Track.StreamIndex)
+ .ToArray();
+
+ private async void SaveTrim_Click(object sender, RoutedEventArgs e) =>
+ await SaveTrimAsync(overwrite: false);
+
+ private void RequestOverwrite_Click(object sender, RoutedEventArgs e)
+ {
+ OverwriteFileText.Text = _clip.Name;
+ OverwriteConfirmOverlay.Visibility = Visibility.Visible;
+ }
+
+ private void CancelOverwrite_Click(object sender, RoutedEventArgs e) =>
+ CancelOverwrite();
+
+ private void CancelOverwrite() =>
+ OverwriteConfirmOverlay.Visibility = Visibility.Collapsed;
+
+ private async void ConfirmOverwrite_Click(object sender, RoutedEventArgs e)
+ {
+ CancelOverwrite();
+ await SaveTrimAsync(overwrite: true);
+ }
+
+ private async Task SaveTrimAsync(bool overwrite)
+ {
+ SaveTrimButton.IsEnabled = false;
+ OverwriteButton.IsEnabled = false;
+ EditorStatusText.Text = Localization.Text("L.Library.Trimming");
+ try
+ {
+ StopNativePlayback();
+ TimeSpan start = TimeSpan.FromSeconds(_selectionStart);
+ TimeSpan end = TimeSpan.FromSeconds(_selectionEnd);
+ IReadOnlyList audioStreams = SelectedAudioStreamIndices();
+ string path = overwrite
+ ? await _library.TrimOverwriteAsync(
+ _rootDirectory,
+ _clip,
+ start,
+ end,
+ audioStreams,
+ _lifetimeCts.Token)
+ : await _library.TrimAsync(
+ _rootDirectory,
+ _clip,
+ start,
+ end,
+ audioStreams,
+ _lifetimeCts.Token);
+ _onSaved(path);
+ DialogResult = true;
+ Close();
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Replay trim failed: {exception}");
+ EditorStatusText.Text = exception.Message;
+ SaveTrimButton.IsEnabled = true;
+ OverwriteButton.IsEnabled = true;
+ }
+ }
+
+ private static string AudioLabel(AudioTrackInfo track, int count)
+ {
+ string title = track.Title ?? "";
+ if (title.Contains("microphone", StringComparison.OrdinalIgnoreCase) ||
+ title.Contains(" mic", StringComparison.OrdinalIgnoreCase))
+ {
+ return Localization.Text("L.Library.MicrophoneTrack");
+ }
+ if (title.Contains("system", StringComparison.OrdinalIgnoreCase) ||
+ title.Contains("game", StringComparison.OrdinalIgnoreCase))
+ {
+ return Localization.Text("L.Library.SystemGameTrack");
+ }
+ if (count == 1)
+ return Localization.Text("L.Library.MixedAudioTrack");
+ if (track.Ordinal == 0)
+ return Localization.Text("L.Library.SystemGameTrack");
+ if (track.Ordinal == 1)
+ return Localization.Text("L.Library.MicrophoneTrack");
+ return Localization.Format("L.Library.AudioTrackNumber", track.Ordinal + 1);
+ }
+
+ private void Header_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ButtonState == MouseButtonState.Pressed)
+ DragMove();
+ }
+
+ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape &&
+ OverwriteConfirmOverlay.Visibility == Visibility.Visible)
+ {
+ CancelOverwrite();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ Close();
+ else if (e.Key == Key.Space)
+ {
+ PlayPause_Click(PlayButton, new RoutedEventArgs());
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Left)
+ {
+ Back_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Right)
+ {
+ Forward_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ }
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void ApplyNativeCornerPreference()
+ {
+ try
+ {
+ nint handle = new WindowInteropHelper(this).Handle;
+ int preference = 2;
+ _ = DwmSetWindowAttribute(handle, 33, ref preference, sizeof(int));
+ }
+ catch
+ {
+ // Rounded corners are cosmetic and unavailable on older Windows builds.
+ }
+ }
+
+ private static T? FindAncestor(DependencyObject? element)
+ where T : DependencyObject
+ {
+ while (element is not null)
+ {
+ if (element is T match)
+ return match;
+ element = VisualTreeHelper.GetParent(element);
+ }
+ return null;
+ }
+
+ private static BitmapImage LoadBitmap(string path, int decodePixelWidth)
+ {
+ var image = new BitmapImage();
+ image.BeginInit();
+ image.CacheOption = BitmapCacheOption.OnLoad;
+ image.DecodePixelWidth = decodePixelWidth;
+ image.UriSource = new Uri(path);
+ image.EndInit();
+ image.Freeze();
+ return image;
+ }
+
+ private static string FormatTime(TimeSpan time, bool milliseconds) =>
+ milliseconds
+ ? $"{(int)time.TotalMinutes:00}:{time.Seconds:00}.{time.Milliseconds:000}"
+ : $"{(int)time.TotalMinutes:00}:{time.Seconds:00}";
+
+ [DllImport("dwmapi.dll")]
+ private static extern int DwmSetWindowAttribute(
+ nint window,
+ int attribute,
+ ref int value,
+ int valueSize);
+}
+
+public sealed class AudioTrackRow : INotifyPropertyChanged
+{
+ private ImageSource? _waveform;
+ private bool _isSelected = true;
+
+ public AudioTrackRow(AudioTrackInfo track, string label)
+ {
+ Track = track;
+ Label = label;
+ Codec = track.Codec.ToUpperInvariant();
+ }
+
+ public AudioTrackInfo Track { get; }
+ public string Label { get; }
+ public string Codec { get; }
+
+ public ImageSource? Waveform
+ {
+ get => _waveform;
+ set
+ {
+ if (ReferenceEquals(_waveform, value))
+ return;
+ _waveform = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set
+ {
+ if (_isSelected == value)
+ return;
+ _isSelected = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void OnPropertyChanged([CallerMemberName] string? name = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/src/Captail/Config.cs b/src/Captail/Config.cs
index c2246fd..7643b63 100644
--- a/src/Captail/Config.cs
+++ b/src/Captail/Config.cs
@@ -28,10 +28,8 @@ public sealed class Config
public int MonitorIndex { get; set; }
/// "source", "720p", "1080p", "1440p", or "2160p".
public string RecordingResolution { get; set; } = "source";
- /// "desktop" or "game".
+ /// "desktop" (with automatic game detection) or "game".
public string CaptureSource { get; set; } = "desktop";
- /// Full path to the selected game executable; PID is resolved when the pipeline starts.
- public string GameExecutablePath { get; set; } = "";
public bool CaptureSystemAudio { get; set; } = true;
public int SystemAudioVolume { get; set; } = 100;
@@ -53,6 +51,7 @@ public sealed class Config
public string OutputDirectory { get; set; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "Captail");
+ public bool OrganizeReplaysByGame { get; set; }
[JsonIgnore]
public static string ConfigPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Captail", "config.json");
@@ -145,7 +144,6 @@ public void CopyFrom(Config source)
MonitorIndex = source.MonitorIndex;
RecordingResolution = source.RecordingResolution;
CaptureSource = source.CaptureSource;
- GameExecutablePath = source.GameExecutablePath;
CaptureSystemAudio = source.CaptureSystemAudio;
SystemAudioVolume = source.SystemAudioVolume;
SystemAudioDeviceId = source.SystemAudioDeviceId;
@@ -157,6 +155,7 @@ public void CopyFrom(Config source)
AudioCodec = source.AudioCodec;
SeparateAudioTracks = source.SeparateAudioTracks;
OutputDirectory = source.OutputDirectory;
+ OrganizeReplaysByGame = source.OrganizeReplaysByGame;
Normalize();
}
@@ -169,7 +168,6 @@ public bool PipelineEquals(Config other) =>
MonitorIndex == other.MonitorIndex &&
string.Equals(RecordingResolution, other.RecordingResolution, StringComparison.Ordinal) &&
string.Equals(CaptureSource, other.CaptureSource, StringComparison.Ordinal) &&
- string.Equals(GameExecutablePath, other.GameExecutablePath, StringComparison.OrdinalIgnoreCase) &&
CaptureSystemAudio == other.CaptureSystemAudio &&
SystemAudioVolume == other.SystemAudioVolume &&
string.Equals(SystemAudioDeviceId, other.SystemAudioDeviceId, StringComparison.Ordinal) &&
@@ -180,7 +178,8 @@ public bool PipelineEquals(Config other) =>
AudioBitrateKbps == other.AudioBitrateKbps &&
string.Equals(AudioCodec, other.AudioCodec, StringComparison.Ordinal) &&
SeparateAudioTracks == other.SeparateAudioTracks &&
- string.Equals(OutputDirectory, other.OutputDirectory, StringComparison.OrdinalIgnoreCase);
+ string.Equals(OutputDirectory, other.OutputDirectory, StringComparison.OrdinalIgnoreCase) &&
+ OrganizeReplaysByGame == other.OrganizeReplaysByGame;
public void Normalize()
{
@@ -205,7 +204,6 @@ public void Normalize()
["source", "720p", "1080p", "1440p", "2160p"],
"source");
CaptureSource = AllowedText(CaptureSource, ["desktop", "game"], "desktop");
- GameExecutablePath = NormalizePath(GameExecutablePath, allowEmpty: true);
SystemAudioVolume = Math.Clamp(SystemAudioVolume, 0, 100);
SystemAudioDeviceId = NormalizeIdentifier(SystemAudioDeviceId);
MicrophoneVolume = Math.Clamp(MicrophoneVolume, 0, 100);
diff --git a/src/Captail/FfmpegAdapter.cs b/src/Captail/FfmpegAdapter.cs
new file mode 100644
index 0000000..f10f010
--- /dev/null
+++ b/src/Captail/FfmpegAdapter.cs
@@ -0,0 +1,402 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+
+namespace Captail;
+
+public sealed record AudioTrackInfo(
+ int StreamIndex,
+ int Ordinal,
+ string Codec,
+ string? Title,
+ int Channels);
+
+public sealed record VideoStreamInfo(
+ string Codec,
+ int Width,
+ int Height,
+ double FrameRate);
+
+public sealed class FfmpegAdapter
+{
+ private readonly string _ffmpegPath;
+ private readonly string _ffprobePath;
+
+ public FfmpegAdapter(string? runtimeDirectory = null)
+ {
+ string root = runtimeDirectory ?? Path.Combine(AppContext.BaseDirectory, "ffmpeg");
+ _ffmpegPath = Path.Combine(root, "ffmpeg.exe");
+ _ffprobePath = Path.Combine(root, "ffprobe.exe");
+ }
+
+ public bool IsAvailable => File.Exists(_ffmpegPath) && File.Exists(_ffprobePath);
+
+ public async Task ReadDurationAsync(
+ string path,
+ CancellationToken cancellationToken = default)
+ {
+ string output = await RunAsync(
+ _ffprobePath,
+ ["-v", "error", "-show_entries", "format=duration", "-of", "json", path],
+ TimeSpan.FromSeconds(15),
+ cancellationToken);
+ using JsonDocument document = JsonDocument.Parse(output);
+ if (document.RootElement.TryGetProperty("format", out JsonElement format) &&
+ format.TryGetProperty("duration", out JsonElement durationElement) &&
+ double.TryParse(
+ durationElement.GetString(),
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double seconds) &&
+ double.IsFinite(seconds) && seconds > 0)
+ {
+ return TimeSpan.FromSeconds(seconds);
+ }
+ return TimeSpan.Zero;
+ }
+
+ public async Task> ReadAudioTracksAsync(
+ string path,
+ CancellationToken cancellationToken = default)
+ {
+ string output = await RunAsync(
+ _ffprobePath,
+ ["-v", "error", "-show_streams", "-of", "json", path],
+ TimeSpan.FromSeconds(15),
+ cancellationToken);
+ using JsonDocument document = JsonDocument.Parse(output);
+ if (!document.RootElement.TryGetProperty("streams", out JsonElement streams))
+ return [];
+
+ var tracks = new List();
+ foreach (JsonElement stream in streams.EnumerateArray())
+ {
+ if (!stream.TryGetProperty("codec_type", out JsonElement type) ||
+ !string.Equals(type.GetString(), "audio", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ int streamIndex = stream.TryGetProperty("index", out JsonElement index)
+ ? index.GetInt32()
+ : tracks.Count;
+ string codec = stream.TryGetProperty("codec_name", out JsonElement codecName)
+ ? codecName.GetString() ?? "audio"
+ : "audio";
+ int channels = stream.TryGetProperty("channels", out JsonElement channelCount)
+ ? channelCount.GetInt32()
+ : 0;
+ string? title = null;
+ if (stream.TryGetProperty("tags", out JsonElement tags) &&
+ tags.TryGetProperty("title", out JsonElement titleElement))
+ {
+ title = titleElement.GetString();
+ }
+ tracks.Add(new AudioTrackInfo(
+ streamIndex,
+ tracks.Count,
+ codec,
+ title,
+ channels));
+ }
+ return tracks;
+ }
+
+ public async Task ReadVideoInfoAsync(
+ string path,
+ CancellationToken cancellationToken = default)
+ {
+ string output = await RunAsync(
+ _ffprobePath,
+ [
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=codec_name,width,height,avg_frame_rate,r_frame_rate",
+ "-of", "json",
+ path,
+ ],
+ TimeSpan.FromSeconds(15),
+ cancellationToken);
+ using JsonDocument document = JsonDocument.Parse(output);
+ if (!document.RootElement.TryGetProperty("streams", out JsonElement streams) ||
+ streams.GetArrayLength() == 0)
+ {
+ return null;
+ }
+
+ JsonElement stream = streams[0];
+ string codec = stream.TryGetProperty("codec_name", out JsonElement codecName)
+ ? codecName.GetString() ?? "video"
+ : "video";
+ int width = stream.TryGetProperty("width", out JsonElement widthElement)
+ ? widthElement.GetInt32()
+ : 0;
+ int height = stream.TryGetProperty("height", out JsonElement heightElement)
+ ? heightElement.GetInt32()
+ : 0;
+ string? frameRateText =
+ stream.TryGetProperty("avg_frame_rate", out JsonElement averageRate)
+ ? averageRate.GetString()
+ : null;
+ if (string.IsNullOrWhiteSpace(frameRateText) || frameRateText == "0/0")
+ {
+ frameRateText = stream.TryGetProperty("r_frame_rate", out JsonElement rawRate)
+ ? rawRate.GetString()
+ : null;
+ }
+ return new VideoStreamInfo(
+ codec,
+ width,
+ height,
+ ParseFrameRate(frameRateText));
+ }
+
+ public async Task CreateThumbnailAsync(
+ string sourcePath,
+ string destinationPath,
+ TimeSpan duration,
+ CancellationToken cancellationToken = default)
+ {
+ double seekSeconds = Math.Clamp(duration.TotalSeconds * 0.2, 0, 5);
+ await CreateThumbnailAtAsync(
+ sourcePath,
+ destinationPath,
+ TimeSpan.FromSeconds(seekSeconds),
+ cancellationToken);
+ }
+
+ public async Task CreateThumbnailAtAsync(
+ string sourcePath,
+ string destinationPath,
+ TimeSpan position,
+ CancellationToken cancellationToken = default)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
+ await RunAsync(
+ _ffmpegPath,
+ [
+ "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-ss", Math.Max(0, position.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture),
+ "-i", sourcePath,
+ "-frames:v", "1",
+ "-vf", "scale=320:-2:flags=lanczos",
+ "-q:v", "4",
+ "-y", destinationPath,
+ ],
+ TimeSpan.FromSeconds(30),
+ cancellationToken);
+ }
+
+ public async Task CreateWaveformAsync(
+ string sourcePath,
+ string destinationPath,
+ int streamIndex,
+ int width,
+ int height,
+ CancellationToken cancellationToken = default)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
+ string filter =
+ $"[0:{streamIndex}]aformat=channel_layouts=stereo," +
+ $"showwavespic=s={Math.Max(320, width)}x{Math.Max(24, height)}:" +
+ "colors=0x53D7B7:draw=full:scale=sqrt[v]";
+ await RunAsync(
+ _ffmpegPath,
+ [
+ "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-i", sourcePath,
+ "-filter_complex", filter,
+ "-map", "[v]",
+ "-frames:v", "1",
+ "-y", destinationPath,
+ ],
+ TimeSpan.FromSeconds(45),
+ cancellationToken);
+ }
+
+ public async Task TrimCopyAsync(
+ string sourcePath,
+ string destinationPath,
+ TimeSpan start,
+ TimeSpan end,
+ IReadOnlyList? audioStreamIndices = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (start < TimeSpan.Zero || end <= start)
+ throw new ArgumentOutOfRangeException(nameof(start));
+
+ string temporaryPath = destinationPath + $".{Guid.NewGuid():N}.tmp" +
+ Path.GetExtension(destinationPath);
+ try
+ {
+ var arguments = new List
+ {
+ "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-ss", start.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture),
+ "-i", sourcePath,
+ "-t", (end - start).TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture),
+ "-map", "0:v:0",
+ "-c", "copy",
+ "-avoid_negative_ts", "make_zero",
+ };
+ if (audioStreamIndices is null)
+ {
+ arguments.AddRange(["-map", "0:a?"]);
+ }
+ else
+ {
+ foreach (int streamIndex in audioStreamIndices.Distinct())
+ arguments.AddRange(["-map", $"0:{streamIndex}?"]);
+ }
+ if (Path.GetExtension(destinationPath).Equals(".mp4", StringComparison.OrdinalIgnoreCase) ||
+ Path.GetExtension(destinationPath).Equals(".mov", StringComparison.OrdinalIgnoreCase))
+ {
+ arguments.AddRange(["-movflags", "+faststart"]);
+ }
+ arguments.AddRange(["-y", temporaryPath]);
+ await RunAsync(
+ _ffmpegPath,
+ arguments,
+ TimeSpan.FromMinutes(5),
+ cancellationToken);
+
+ if (!File.Exists(temporaryPath) || new FileInfo(temporaryPath).Length == 0)
+ throw new InvalidOperationException("FFmpeg produced an empty clip.");
+ File.Move(temporaryPath, destinationPath);
+ }
+ finally
+ {
+ if (File.Exists(temporaryPath))
+ File.Delete(temporaryPath);
+ }
+ }
+
+ public async Task TrimOverwriteAsync(
+ string sourcePath,
+ TimeSpan start,
+ TimeSpan end,
+ IReadOnlyList? audioStreamIndices = null,
+ CancellationToken cancellationToken = default)
+ {
+ string directory = Path.GetDirectoryName(sourcePath)!;
+ string replacementPath = Path.Combine(
+ directory,
+ $".{Path.GetFileNameWithoutExtension(sourcePath)}." +
+ $"{Guid.NewGuid():N}.replacement{Path.GetExtension(sourcePath)}");
+ try
+ {
+ await TrimCopyAsync(
+ sourcePath,
+ replacementPath,
+ start,
+ end,
+ audioStreamIndices,
+ cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Replace(
+ replacementPath,
+ sourcePath,
+ destinationBackupFileName: null,
+ ignoreMetadataErrors: true);
+ }
+ finally
+ {
+ if (File.Exists(replacementPath))
+ File.Delete(replacementPath);
+ }
+ }
+
+ private static async Task RunAsync(
+ string executable,
+ IReadOnlyList arguments,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ if (!File.Exists(executable))
+ throw new FileNotFoundException("Bundled FFmpeg runtime is unavailable.", executable);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = executable,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ foreach (string argument in arguments)
+ startInfo.ArgumentList.Add(argument);
+
+ using var process = new Process { StartInfo = startInfo };
+ if (!process.Start())
+ throw new InvalidOperationException($"Could not start {Path.GetFileName(executable)}.");
+
+ Task standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ Task standardError = process.StandardError.ReadToEndAsync(cancellationToken);
+ using var timeoutSource = new CancellationTokenSource(timeout);
+ using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ timeoutSource.Token);
+ try
+ {
+ await process.WaitForExitAsync(linkedSource.Token);
+ string output = await standardOutput;
+ string error = await standardError;
+ if (process.ExitCode != 0)
+ {
+ string message = string.IsNullOrWhiteSpace(error)
+ ? $"{Path.GetFileName(executable)} failed with exit code {process.ExitCode}."
+ : error.Trim();
+ throw new InvalidOperationException(message);
+ }
+ return output;
+ }
+ catch (OperationCanceledException)
+ {
+ TryKill(process);
+ if (timeoutSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ throw new TimeoutException($"{Path.GetFileName(executable)} timed out after {timeout.TotalSeconds:0} seconds.");
+ throw;
+ }
+ }
+
+ private static void TryKill(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Process is already gone or access was revoked.
+ }
+ }
+
+ private static double ParseFrameRate(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return 0;
+ string[] parts = value.Split('/', 2);
+ if (!double.TryParse(
+ parts[0],
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double numerator))
+ {
+ return 0;
+ }
+ if (parts.Length == 1)
+ return double.IsFinite(numerator) ? Math.Max(0, numerator) : 0;
+ if (!double.TryParse(
+ parts[1],
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double denominator) || denominator == 0)
+ {
+ return 0;
+ }
+ double rate = numerator / denominator;
+ return double.IsFinite(rate) ? Math.Max(0, rate) : 0;
+ }
+}
diff --git a/src/Captail/FfplayHost.cs b/src/Captail/FfplayHost.cs
new file mode 100644
index 0000000..8fe5ecc
--- /dev/null
+++ b/src/Captail/FfplayHost.cs
@@ -0,0 +1,494 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Threading;
+
+namespace Captail;
+
+[SuppressMessage(
+ "Usage",
+ "CA2216:Disposable types should declare finalizer",
+ Justification = "WPF HwndHost owns the native window and calls DestroyWindowCore on the UI thread.")]
+public sealed class FfplayHost : HwndHost
+{
+ private const int GwlStyle = -16;
+ private const long WsChild = 0x40000000L;
+ private const long WsVisible = 0x10000000L;
+ private const long WsCaption = 0x00C00000L;
+ private const long WsThickFrame = 0x00040000L;
+ private const long WsPopup = unchecked((long)0x80000000);
+ private const string HostWindowClass = "CaptailPreviewHostWindow";
+ private const int ErrorClassAlreadyExists = 1410;
+ private const int BlackBrush = 4;
+ private const uint SwpNoZOrder = 0x0004;
+ private const uint SwpNoActivate = 0x0010;
+ private const uint SwpFrameChanged = 0x0020;
+ private const int SwHide = 0;
+ private const int SwShowNoActivate = 4;
+ private static readonly object HostClassLock = new();
+ private static readonly NativeWindowProcedure HostWindowProcedure = HostWindowProc;
+ private static bool _hostClassRegistered;
+
+ private readonly string _ffplayPath = Path.Combine(
+ AppContext.BaseDirectory,
+ "ffmpeg",
+ "ffplay.exe");
+ private readonly List _audioPlayers = [];
+ private readonly List _drainTasks = [];
+ private nint _hostHandle;
+ private nint _playerHandle;
+ private Process? _player;
+ private CancellationTokenSource? _attachCts;
+ private (int Width, int Height) _requestedSize;
+
+ public bool IsReady => _playerHandle != 0 && _player is { HasExited: false };
+
+ internal bool TryValidateGeometry(out string details)
+ {
+ if (_hostHandle == 0 || _playerHandle == 0 ||
+ !GetClientRect(_hostHandle, out NativeRect host) ||
+ !GetClientRect(_playerHandle, out NativeRect player))
+ {
+ details = "preview window is not ready";
+ return false;
+ }
+
+ int hostWidth = host.Right - host.Left;
+ int hostHeight = host.Bottom - host.Top;
+ int playerWidth = player.Right - player.Left;
+ int playerHeight = player.Bottom - player.Top;
+ details =
+ $"requested={_requestedSize.Width}x{_requestedSize.Height}, " +
+ $"host={hostWidth}x{hostHeight}, player={playerWidth}x{playerHeight}";
+ return hostWidth > 0 && hostHeight > 0 &&
+ playerWidth == hostWidth && playerHeight == hostHeight;
+ }
+
+ protected override HandleRef BuildWindowCore(HandleRef hwndParent)
+ {
+ EnsureHostWindowClass();
+ // Keep native host hidden until FFplay is attached and sized. Otherwise
+ // Windows paints STATIC's default light background for one frame.
+ _hostHandle = CreateWindowExW(
+ 0,
+ HostWindowClass,
+ "",
+ (uint)WsChild,
+ 0,
+ 0,
+ 1,
+ 1,
+ hwndParent.Handle,
+ 0,
+ 0,
+ 0);
+ if (_hostHandle == 0)
+ throw new InvalidOperationException("Could not create embedded preview host.");
+ return new HandleRef(this, _hostHandle);
+ }
+
+ protected override void DestroyWindowCore(HandleRef hwnd)
+ {
+ Stop();
+ if (hwnd.Handle != 0)
+ DestroyWindow(hwnd.Handle);
+ _hostHandle = 0;
+ }
+
+ protected override void OnWindowPositionChanged(Rect rcBoundingBox)
+ {
+ base.OnWindowPositionChanged(rcBoundingBox);
+ ResizePlayer();
+ _ = Dispatcher.BeginInvoke(
+ DispatcherPriority.Render,
+ () => ResizePlayer(frameChanged: true));
+ }
+
+ protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
+ {
+ base.OnDpiChanged(oldDpi, newDpi);
+ _ = Dispatcher.BeginInvoke(
+ DispatcherPriority.Render,
+ () => ResizePlayer(frameChanged: true));
+ }
+
+ public async Task PlayAsync(
+ string path,
+ TimeSpan start,
+ TimeSpan duration,
+ IReadOnlyList? audioStreamIndices = null,
+ CancellationToken cancellationToken = default)
+ {
+ Stop();
+ if (!File.Exists(_ffplayPath))
+ throw new FileNotFoundException("Bundled FFplay runtime is unavailable.", _ffplayPath);
+
+ string title = $"CaptailPreview_{Guid.NewGuid():N}";
+ (int previewWidth, int previewHeight) = HostClientSize();
+ _requestedSize = (previewWidth, previewHeight);
+ var startInfo = CreateBaseStartInfo();
+ AddArguments(
+ startInfo,
+ [
+ "-noborder", "-autoexit", "-an",
+ "-x", previewWidth.ToString(CultureInfo.InvariantCulture),
+ "-y", previewHeight.ToString(CultureInfo.InvariantCulture),
+ "-left", "-32000", "-top", "-32000",
+ "-window_title", title,
+ "-ss", Seconds(start),
+ "-t", Seconds(TimeSpan.FromSeconds(Math.Max(0.1, duration.TotalSeconds))),
+ path,
+ ]);
+
+ var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ var startupClock = Stopwatch.StartNew();
+ if (!process.Start())
+ throw new InvalidOperationException("Could not start bundled preview player.");
+ _player = process;
+ Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+ _drainTasks.Add(process.StandardOutput.ReadToEndAsync(cancellationToken));
+ _attachCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ try
+ {
+ nint handle = 0;
+ for (int attempt = 0; attempt < 100 && !process.HasExited; attempt++)
+ {
+ _attachCts.Token.ThrowIfCancellationRequested();
+ process.Refresh();
+ handle = process.MainWindowHandle;
+ if (handle == 0)
+ handle = FindWindowW(null, title);
+ if (handle != 0 && _hostHandle != 0)
+ break;
+ await Task.Delay(20, _attachCts.Token);
+ }
+
+ if (handle == 0 || process.HasExited)
+ {
+ string error = await stderrTask;
+ throw new InvalidOperationException(
+ string.IsNullOrWhiteSpace(error)
+ ? "Preview player did not create a video window."
+ : error.Trim());
+ }
+
+ ShowWindow(handle, SwHide);
+ SetParent(handle, _hostHandle);
+ long style = GetWindowLongPtrW(handle, GwlStyle).ToInt64();
+ style &= ~(WsPopup | WsCaption | WsThickFrame | WsVisible);
+ style |= WsChild;
+ SetWindowLongPtrW(handle, GwlStyle, new nint(style));
+ _playerHandle = handle;
+ ResizePlayer(frameChanged: true);
+
+ // Give SDL time to replace its initial blank surface while window is
+ // still off-screen and hidden. This removes visible startup flash.
+ await Task.Delay(80, _attachCts.Token);
+ ResizePlayer(frameChanged: true);
+
+ TimeSpan startupDelay = startupClock.Elapsed;
+ StartAudioPlayers(
+ path,
+ start + startupDelay,
+ duration - startupDelay,
+ audioStreamIndices ?? [],
+ cancellationToken);
+
+ ShowWindow(_hostHandle, SwShowNoActivate);
+ ShowWindow(_playerHandle, SwShowNoActivate);
+ ResizePlayer(frameChanged: true);
+ _ = Dispatcher.BeginInvoke(
+ DispatcherPriority.Render,
+ () => ResizePlayer(frameChanged: true));
+ return startupDelay;
+ }
+ catch
+ {
+ Stop();
+ throw;
+ }
+ }
+
+ public void Stop()
+ {
+ _attachCts?.Cancel();
+ _attachCts?.Dispose();
+ _attachCts = null;
+ if (_hostHandle != 0)
+ ShowWindow(_hostHandle, SwHide);
+ _playerHandle = 0;
+
+ StopProcess(_player);
+ _player = null;
+ foreach (Process process in _audioPlayers)
+ StopProcess(process);
+ _audioPlayers.Clear();
+ _drainTasks.Clear();
+ }
+
+ private void StartAudioPlayers(
+ string path,
+ TimeSpan start,
+ TimeSpan duration,
+ IReadOnlyList streamIndices,
+ CancellationToken cancellationToken)
+ {
+ if (duration <= TimeSpan.Zero)
+ return;
+
+ foreach (int streamIndex in streamIndices.Distinct())
+ {
+ var startInfo = CreateBaseStartInfo();
+ AddArguments(
+ startInfo,
+ [
+ "-nodisp", "-autoexit", "-vn",
+ "-ast", streamIndex.ToString(CultureInfo.InvariantCulture),
+ "-ss", Seconds(start),
+ "-t", Seconds(duration),
+ path,
+ ]);
+ Process? process = new() { StartInfo = startInfo };
+ try
+ {
+ if (!process.Start())
+ continue;
+ _audioPlayers.Add(process);
+ _drainTasks.Add(process.StandardOutput.ReadToEndAsync(cancellationToken));
+ _drainTasks.Add(process.StandardError.ReadToEndAsync(cancellationToken));
+ process = null;
+ }
+ finally
+ {
+ process?.Dispose();
+ }
+ }
+ }
+
+ private ProcessStartInfo CreateBaseStartInfo()
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = _ffplayPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ };
+ AddArguments(startInfo, ["-hide_banner", "-loglevel", "error", "-nostats"]);
+ return startInfo;
+ }
+
+ private static void AddArguments(ProcessStartInfo startInfo, IEnumerable arguments)
+ {
+ foreach (string argument in arguments)
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ private void ResizePlayer(bool frameChanged = false)
+ {
+ if (_playerHandle == 0)
+ return;
+ (int width, int height) = HostClientSize();
+ SetWindowPos(
+ _playerHandle,
+ 0,
+ 0,
+ 0,
+ width,
+ height,
+ SwpNoZOrder | SwpNoActivate |
+ (frameChanged ? SwpFrameChanged : 0));
+ PostMessageW(
+ _playerHandle,
+ 0x0005,
+ 0,
+ new nint(((height & 0xFFFF) << 16) | (width & 0xFFFF)));
+ }
+
+ private (int Width, int Height) HostClientSize()
+ {
+ if (_hostHandle != 0 && GetClientRect(_hostHandle, out NativeRect rect))
+ {
+ int width = rect.Right - rect.Left;
+ int height = rect.Bottom - rect.Top;
+ if (width > 1 && height > 1)
+ return (width, height);
+ }
+ DpiScale dpi = VisualTreeHelper.GetDpi(this);
+ return (
+ Math.Max(1, (int)Math.Round(ActualWidth * dpi.DpiScaleX)),
+ Math.Max(1, (int)Math.Round(ActualHeight * dpi.DpiScaleY)));
+ }
+
+ private static string Seconds(TimeSpan value) =>
+ Math.Max(0, value.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture);
+
+ private static void StopProcess(Process? process)
+ {
+ if (process is null)
+ return;
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(1000);
+ }
+ }
+ catch
+ {
+ // Player already exited.
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+
+ private static void EnsureHostWindowClass()
+ {
+ if (_hostClassRegistered)
+ return;
+ lock (HostClassLock)
+ {
+ if (_hostClassRegistered)
+ return;
+ var windowClass = new WindowClassEx
+ {
+ Size = (uint)Marshal.SizeOf(),
+ WindowProcedure = HostWindowProcedure,
+ Instance = GetModuleHandleW(null),
+ BackgroundBrush = GetStockObject(BlackBrush),
+ ClassName = HostWindowClass,
+ };
+ ushort atom = RegisterClassExW(ref windowClass);
+ int error = Marshal.GetLastWin32Error();
+ if (atom == 0 && error != ErrorClassAlreadyExists)
+ {
+ throw new InvalidOperationException(
+ $"Could not register embedded preview host ({error}).");
+ }
+ _hostClassRegistered = true;
+ }
+ }
+
+ private static nint HostWindowProc(
+ nint window,
+ uint message,
+ nint wParam,
+ nint lParam) =>
+ DefWindowProcW(window, message, wParam, lParam);
+
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)]
+ private delegate nint NativeWindowProcedure(
+ nint window,
+ uint message,
+ nint wParam,
+ nint lParam);
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WindowClassEx
+ {
+ public uint Size;
+ public uint Style;
+ public NativeWindowProcedure WindowProcedure;
+ public int ClassExtra;
+ public int WindowExtra;
+ public nint Instance;
+ public nint Icon;
+ public nint Cursor;
+ public nint BackgroundBrush;
+ public string? MenuName;
+ public string ClassName;
+ public nint SmallIcon;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativeRect
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern nint CreateWindowExW(
+ uint extendedStyle,
+ string className,
+ string windowName,
+ uint style,
+ int x,
+ int y,
+ int width,
+ int height,
+ nint parent,
+ nint menu,
+ nint instance,
+ nint parameter);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern ushort RegisterClassExW(ref WindowClassEx windowClass);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern nint DefWindowProcW(
+ nint window,
+ uint message,
+ nint wParam,
+ nint lParam);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
+ private static extern nint GetModuleHandleW(string? moduleName);
+
+ [DllImport("gdi32.dll")]
+ private static extern nint GetStockObject(int objectIndex);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DestroyWindow(nint window);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern nint SetParent(nint child, nint newParent);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
+ private static extern nint GetWindowLongPtrW(nint window, int index);
+
+ [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
+ private static extern nint SetWindowLongPtrW(nint window, int index, nint newValue);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetWindowPos(
+ nint window,
+ nint insertAfter,
+ int x,
+ int y,
+ int width,
+ int height,
+ uint flags);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetClientRect(nint window, out NativeRect rect);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ShowWindow(nint window, int command);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool PostMessageW(
+ nint window,
+ uint message,
+ nint wParam,
+ nint lParam);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern nint FindWindowW(string? className, string? windowName);
+}
diff --git a/src/Captail/Interop/CaptureInterop.cs b/src/Captail/Interop/CaptureInterop.cs
index ef93415..67eac49 100644
--- a/src/Captail/Interop/CaptureInterop.cs
+++ b/src/Captail/Interop/CaptureInterop.cs
@@ -1,7 +1,4 @@
-using System.Diagnostics;
-using System.IO;
using System.Runtime.InteropServices;
-using System.Text;
namespace Captail.Interop;
@@ -26,12 +23,6 @@ private static extern bool EnumDisplayDevices(
ref DisplayDevice displayDevice,
uint flags);
- [DllImport("user32.dll", CharSet = CharSet.Unicode)]
- private static extern int GetClassName(nint window, StringBuilder className, int maxCount);
-
- [DllImport("user32.dll")]
- private static extern bool GetClientRect(nint window, out Rect rect);
-
[StructLayout(LayoutKind.Sequential)]
private struct Rect
{
@@ -118,68 +109,4 @@ public static List EnumerateMonitors()
return monitors;
}
- public static string BuildObsWindowSelector(string executablePath)
- {
- Process? target = FindProcess(executablePath, requireWindow: true);
- if (target is null)
- return $"::{Encode(Path.GetFileName(executablePath))}";
-
- using (target)
- {
- var className = new StringBuilder(256);
- _ = GetClassName(target.MainWindowHandle, className, className.Capacity);
- return $"{Encode(target.MainWindowTitle)}:{Encode(className.ToString())}:" +
- $"{Encode(Path.GetFileName(executablePath))}";
- }
- }
-
- public static bool IsProcessRunning(string executablePath)
- {
- using Process? process = FindProcess(executablePath, requireWindow: false);
- return process is not null;
- }
-
- public static (int Width, int Height)? GetGameClientSize(string executablePath)
- {
- using Process? process = FindProcess(executablePath, requireWindow: true);
- if (process is null ||
- !GetClientRect(process.MainWindowHandle, out Rect rect))
- {
- return null;
- }
-
- int width = rect.Right - rect.Left;
- int height = rect.Bottom - rect.Top;
- return width > 0 && height > 0 ? (width, height) : null;
- }
-
- private static Process? FindProcess(string executablePath, bool requireWindow)
- {
- foreach (Process process in Process.GetProcesses())
- {
- try
- {
- if ((!requireWindow || process.MainWindowHandle != 0) &&
- string.Equals(
- process.MainModule?.FileName,
- executablePath,
- StringComparison.OrdinalIgnoreCase))
- {
- return process;
- }
- }
- catch
- {
- // Protected system processes cannot be the selected game.
- }
-
- process.Dispose();
- }
-
- return null;
- }
-
- private static string Encode(string value) =>
- value.Replace("#", "#22", StringComparison.Ordinal)
- .Replace(":", "#3A", StringComparison.Ordinal);
}
diff --git a/src/Captail/Languages/Strings.en.xaml b/src/Captail/Languages/Strings.en.xaml
index db77f63..b45913d 100644
--- a/src/Captail/Languages/Strings.en.xaml
+++ b/src/Captail/Languages/Strings.en.xaml
@@ -87,7 +87,6 @@
Desktop
Game · OBS Game Capture
Game
- Game Capture · anti-cheat compatible
Codec
Bitrate
Auto · adaptive load
@@ -104,9 +103,7 @@
desktop
game
source
- {0} · not running
- Choose a running game…
- Desktop captures the selected monitor; protected video may appear black without stopping the buffer. Game Capture hooks the selected game directly for cleaner high-FPS recording.
+ Desktop records the selected monitor and automatically switches to direct Game Capture when a fullscreen game appears. Game Capture waits for any fullscreen game and never records the desktop. The replay buffer stays active in both modes.
AV1 — newest and most efficient. Cleaner at the same bitrate, or smaller files at the same quality. Hardware support includes GeForce RTX 40/50, Radeon RX 7000 and Intel Arc.
HEVC — nearly as compact and supported by more modern GPUs.
H.264 — uses more space, but opens almost everywhere.
@@ -136,6 +133,42 @@
{0} free
Drive unavailable
No data
+ Organize games into folders
+ Detected games save into their own folders. Replays saved while Desktop is active stay in the main folder.
+
+ RECENT REPLAYS
+ Saved replays will appear here.
+ Refresh replays
+ Show in folder
+ Trim
+ Delete
+ Delete this replay?
+ File will be moved to Recycle Bin.
+ Cancel
+ {0} · {1}
+ Trim replay
+ Keep selected range
+ Start
+ End
+ Play / pause
+ Save trimmed copy
+ Saving clip…
+ Creates a new file without re-encoding. Original replay stays untouched.
+ Trimmed replay saved
+ VIDEO
+ AUDIO TRACKS
+ Click a track to keep or remove it
+ This replay has no audio tracks.
+ Mixed audio
+ System / game
+ Microphone
+ Audio {0}
+ Selected · {0}
+ Back 5 seconds
+ Forward 5 seconds
+ Overwrite
+ Overwrite original replay?
+ Selected range and enabled audio tracks will replace original file. This cannot be undone.
Save replay
Saves current buffer
@@ -214,6 +247,8 @@
GPU capabilities have not been checked yet.
Game · Game Capture
Game · waiting for capture
+ Desktop
+ Game · {0}
Recording module is already in use.
Replay buffer is not running.
Previous replay is still being saved.
diff --git a/src/Captail/Languages/Strings.ru.xaml b/src/Captail/Languages/Strings.ru.xaml
index 82bb287..975dc29 100644
--- a/src/Captail/Languages/Strings.ru.xaml
+++ b/src/Captail/Languages/Strings.ru.xaml
@@ -87,7 +87,6 @@
Рабочий стол
Игра · OBS Game Capture
Игра
- Game Capture · совместимость с античитами
Кодек
Битрейт
Авто · по нагрузке
@@ -104,9 +103,7 @@
рабочий стол
игра
исходное
- {0} · не запущена
- Выберите запущенную игру…
- «Рабочий стол» записывает выбранный монитор; защищённое видео может быть чёрным, но буфер продолжит работу. Game Capture напрямую захватывает выбранную игру и лучше подходит для высокого FPS.
+ «Рабочий стол» записывает выбранный монитор и автоматически переключается на прямой захват при появлении полноэкранной игры. Game Capture ждёт любую полноэкранную игру и никогда не записывает рабочий стол. Буфер работает непрерывно в обоих режимах.
AV1 — самый современный и эффективный. Чище при том же битрейте или меньше файл при том же качестве. Поддержка есть на GeForce RTX 40/50, Radeon RX 7000 и Intel Arc.
HEVC — почти такой же компактный и поддерживается большим числом современных GPU.
H.264 — занимает больше места, зато открывается почти везде.
@@ -136,6 +133,42 @@
{0} свободно
Диск недоступен
Нет данных
+ Раскладывать игры по папкам
+ Найденные игры сохраняются в свои папки. Повторы, сохранённые во время записи рабочего стола, остаются в основной папке.
+
+ ПОСЛЕДНИЕ ПОВТОРЫ
+ Сохранённые повторы появятся здесь.
+ Обновить список
+ Показать в папке
+ Обрезать
+ Удалить
+ Удалить этот повтор?
+ Файл будет перемещён в корзину.
+ Отмена
+ {0} · {1}
+ Обрезать повтор
+ Оставить выбранный участок
+ Начало
+ Конец
+ Воспроизвести / пауза
+ Сохранить копию
+ Сохраняю клип…
+ Создаёт новый файл без перекодирования. Исходный повтор останется нетронутым.
+ Обрезанный повтор сохранён
+ ВИДЕО
+ АУДИОДОРОЖКИ
+ Нажмите дорожку, чтобы оставить или убрать её
+ В этом повторе нет аудиодорожек.
+ Общий звук
+ Система / игра
+ Микрофон
+ Аудио {0}
+ Выбрано · {0}
+ Назад на 5 секунд
+ Вперёд на 5 секунд
+ Перезаписать
+ Перезаписать исходный повтор?
+ Выбранный участок и включённые аудиодорожки заменят исходный файл. Отменить это действие нельзя.
Сохранить повтор
Сохраняет текущий буфер
@@ -214,6 +247,8 @@
Возможности GPU ещё не проверены.
Игра · Game Capture
Игра · ожидание захвата
+ Рабочий стол
+ Игра · {0}
Модуль записи уже используется.
Буфер не запущен.
Предыдущий повтор ещё сохраняется.
diff --git a/src/Captail/ObsNative.cs b/src/Captail/ObsNative.cs
index 2d10dd9..b275ec5 100644
--- a/src/Captail/ObsNative.cs
+++ b/src/Captail/ObsNative.cs
@@ -190,6 +190,12 @@ internal static extern nint obs_source_create(
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
internal static extern void obs_source_remove(nint source);
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void obs_source_inc_showing(nint source);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void obs_source_dec_showing(nint source);
+
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
internal static extern void obs_source_set_audio_mixers(nint source, uint mixers);
diff --git a/src/Captail/ObsReplayEngine.cs b/src/Captail/ObsReplayEngine.cs
index 21eb791..312c0bd 100644
--- a/src/Captail/ObsReplayEngine.cs
+++ b/src/Captail/ObsReplayEngine.cs
@@ -30,6 +30,8 @@ public sealed class ObsReplayEngine : IDisposable
private readonly List _audioEncoders = [];
private nint _videoSource;
+ private nint _desktopVideoSource;
+ private nint _gameVideoSource;
private nint _videoEncoder;
private nint _output;
private nint _outputSignals;
@@ -46,6 +48,9 @@ public sealed class ObsReplayEngine : IDisposable
private uint _outputHeight;
private uint _baseWidth;
private uint _baseHeight;
+ private bool _automaticGameActive;
+ private bool _automaticGameSourceShowing;
+ private string _activeGameExecutable = "";
public event Action? Faulted;
@@ -57,6 +62,7 @@ public sealed class ObsReplayEngine : IDisposable
EncoderCapabilities.Failed(
Localization.Text("L.Engine.CapabilitiesPending"));
public bool IsGameCapture { get; }
+ public bool IsAutomaticCapture { get; }
public bool IsActive =>
_started &&
_output != 0 &&
@@ -68,6 +74,18 @@ public string Description
{
get
{
+ if (IsAutomaticCapture)
+ {
+ string gameName = Path.GetFileNameWithoutExtension(
+ _activeGameExecutable);
+ return _automaticGameActive
+ ? Localization.Format(
+ "L.Engine.AutoGameCaptured",
+ string.IsNullOrWhiteSpace(gameName)
+ ? Localization.Text("L.Video.Game")
+ : gameName)
+ : Localization.Text("L.Engine.AutoDesktop");
+ }
if (!IsGameCapture)
return Localization.Text("L.Video.Desktop");
return IsGameHooked
@@ -77,11 +95,14 @@ public string Description
}
public bool IsGameHooked =>
- IsGameCapture && _videoSource != 0 && ReadBoolProcedure(
- ObsNative.obs_source_get_proc_handler(_videoSource),
+ _gameVideoSource != 0 &&
+ ReadBoolProcedure(
+ ObsNative.obs_source_get_proc_handler(_gameVideoSource),
"get_hooked",
"hooked");
+ public string ActiveGameExecutable => _activeGameExecutable;
+
public bool IsHealthy
{
get
@@ -125,6 +146,53 @@ public int AvailableReplaySeconds
public uint TotalRenderedFrames => ObsNative.obs_get_total_frames();
public uint LaggedRenderedFrames => ObsNative.obs_get_lagged_frames();
+ ///
+ /// Updates game-hook metadata and Desktop fallback without restarting
+ /// encoder or replay buffer.
+ /// Must be called on Captail's serialized OBS thread.
+ ///
+ public bool RefreshCaptureState()
+ {
+ if (_gameVideoSource == 0)
+ return false;
+
+ bool hooked = IsGameHooked;
+ string executable = hooked
+ ? ReadStringProcedure(
+ ObsNative.obs_source_get_proc_handler(_gameVideoSource),
+ "get_hooked",
+ "executable")
+ : "";
+
+ if (IsGameCapture)
+ {
+ _activeGameExecutable = hooked ? executable : "";
+ return false;
+ }
+
+ if (_desktopVideoSource == 0)
+ return false;
+
+ if (hooked == _automaticGameActive)
+ {
+ if (hooked && !string.IsNullOrWhiteSpace(executable))
+ _activeGameExecutable = executable;
+ return false;
+ }
+
+ nint target = hooked ? _gameVideoSource : _desktopVideoSource;
+ ObsNative.obs_set_output_source(0, target);
+ _videoSource = target;
+ _automaticGameActive = hooked;
+ _activeGameExecutable = hooked ? executable : "";
+ Log.Write(
+ hooked
+ ? $"Automatic capture switched to Game Capture: " +
+ $"{Path.GetFileName(_activeGameExecutable)}"
+ : "Automatic capture returned to Desktop Capture.");
+ return true;
+ }
+
public ObsReplayEngine(Config config)
{
_config = config;
@@ -132,6 +200,7 @@ public ObsReplayEngine(Config config)
config.CaptureSource,
"game",
StringComparison.OrdinalIgnoreCase);
+ IsAutomaticCapture = !IsGameCapture;
_savedCallback = OnReplaySaved;
_stoppedCallback = OnOutputStopped;
}
@@ -238,7 +307,13 @@ public ReplaySaveOperation BeginSaveReplay(
return new ReplaySaveOperation(
WaitForSaveCompletionAsync(completion, cancellationToken),
- initialMuxBytes);
+ initialMuxBytes,
+ IsGameHooked
+ ? ReadStringProcedure(
+ ObsNative.obs_source_get_proc_handler(_gameVideoSource),
+ "get_hooked",
+ "executable")
+ : null);
}
public Task SaveReplayAsync(
@@ -359,10 +434,9 @@ private void InitializeObs()
: monitors.FirstOrDefault()
?? throw new InvalidOperationException(
Localization.Text("L.Engine.MonitorMissing"));
- (int captureWidth, int captureHeight) = IsGameCapture
- ? CaptureInterop.GetGameClientSize(_config.GameExecutablePath) ??
- (monitor.Width, monitor.Height)
- : (monitor.Width, monitor.Height);
+ // Both automatic modes can hook different games over pipeline lifetime,
+ // so fixed OBS canvas follows selected monitor rather than one process.
+ (int captureWidth, int captureHeight) = (monitor.Width, monitor.Height);
_baseWidth = (uint)captureWidth;
_baseHeight = (uint)captureHeight;
(uint outputWidth, uint outputHeight) = ResolveOutputSize(
@@ -560,51 +634,22 @@ private void CreateSources()
? monitors[_config.MonitorIndex]
: monitors.First();
- nint videoSettings = ObsNative.obs_data_create();
- try
+ if (IsAutomaticCapture)
{
- if (IsGameCapture)
- {
- string selector = CaptureInterop.BuildObsWindowSelector(
- _config.GameExecutablePath);
- ObsNative.obs_data_set_string(videoSettings, "capture_mode", "window");
- ObsNative.obs_data_set_string(videoSettings, "window", selector);
- ObsNative.obs_data_set_int(videoSettings, "priority", 2);
- ObsNative.obs_data_set_bool(videoSettings, "capture_cursor", true);
- ObsNative.obs_data_set_bool(videoSettings, "limit_framerate", false);
- ObsNative.obs_data_set_bool(videoSettings, "anti_cheat_hook", true);
- ObsNative.obs_data_set_int(videoSettings, "hook_rate", 1);
- ObsNative.obs_data_set_bool(
- videoSettings,
- "capture_audio",
- _config.CaptureSystemAudio);
- _videoSource = ObsNative.obs_source_create(
- "game_capture",
- "Captail Game Capture",
- videoSettings,
- 0);
- }
- else
- {
- // WGC handles secure/DRM surfaces and DXGI resets more reliably;
- // protected regions become black without stopping the source.
- ObsNative.obs_data_set_int(videoSettings, "method", 2);
- ObsNative.obs_data_set_string(
- videoSettings,
- "monitor_id",
- monitor.DeviceId);
- ObsNative.obs_data_set_bool(videoSettings, "capture_cursor", true);
- ObsNative.obs_data_set_bool(videoSettings, "force_sdr", false);
- _videoSource = ObsNative.obs_source_create(
- "monitor_capture",
- "Captail Display Capture",
- videoSettings,
- 0);
- }
+ _desktopVideoSource = CreateMonitorSource(monitor);
+ _gameVideoSource = CreateGameSource(desktopFallback: true);
+ _videoSource = _desktopVideoSource;
+
+ // Game Capture stops looking for a target when it is not visible.
+ // Keep only its lightweight hook detector visible while Desktop is
+ // rendered. Output still contains one video source at a time.
+ ObsNative.obs_source_inc_showing(_gameVideoSource);
+ _automaticGameSourceShowing = true;
}
- finally
+ else
{
- ObsNative.obs_data_release(videoSettings);
+ _gameVideoSource = CreateGameSource(desktopFallback: false);
+ _videoSource = _gameVideoSource;
}
if (_videoSource == 0)
@@ -614,9 +659,9 @@ private void CreateSources()
const uint systemMix = 1u;
if (IsGameCapture && _config.CaptureSystemAudio)
{
- ObsNative.obs_source_set_audio_mixers(_videoSource, systemMix);
+ ObsNative.obs_source_set_audio_mixers(_gameVideoSource, systemMix);
ObsNative.obs_source_set_volume(
- _videoSource,
+ _gameVideoSource,
NormalizeVolume(_config.SystemAudioVolume));
}
@@ -654,6 +699,74 @@ private void CreateSources()
}
}
+ private nint CreateGameSource(bool desktopFallback)
+ {
+ nint settings = ObsNative.obs_data_create();
+ try
+ {
+ ObsNative.obs_data_set_string(
+ settings,
+ "capture_mode",
+ "any_fullscreen");
+ ObsNative.obs_data_set_bool(settings, "capture_cursor", true);
+ ObsNative.obs_data_set_bool(settings, "limit_framerate", false);
+ ObsNative.obs_data_set_bool(settings, "anti_cheat_hook", true);
+ ObsNative.obs_data_set_int(settings, "hook_rate", 1);
+ // Automatic mode keeps WASAPI active across source changes. This
+ // prevents an audio gap and duplicate system/game audio at switch.
+ ObsNative.obs_data_set_bool(
+ settings,
+ "capture_audio",
+ !desktopFallback && _config.CaptureSystemAudio);
+ nint source = ObsNative.obs_source_create(
+ "game_capture",
+ desktopFallback
+ ? "Captail Automatic Game Capture"
+ : "Captail Game Capture",
+ settings,
+ 0);
+ if (source == 0)
+ {
+ throw new InvalidOperationException(
+ Localization.Text("L.Engine.VideoSourceFailed"));
+ }
+ return source;
+ }
+ finally
+ {
+ ObsNative.obs_data_release(settings);
+ }
+ }
+
+ private static nint CreateMonitorSource(CaptureInterop.MonitorInfo monitor)
+ {
+ nint settings = ObsNative.obs_data_create();
+ try
+ {
+ // WGC handles secure/DRM surfaces and DXGI resets more reliably;
+ // protected regions become black without stopping the source.
+ ObsNative.obs_data_set_int(settings, "method", 2);
+ ObsNative.obs_data_set_string(settings, "monitor_id", monitor.DeviceId);
+ ObsNative.obs_data_set_bool(settings, "capture_cursor", true);
+ ObsNative.obs_data_set_bool(settings, "force_sdr", false);
+ nint source = ObsNative.obs_source_create(
+ "monitor_capture",
+ "Captail Display Capture",
+ settings,
+ 0);
+ if (source == 0)
+ {
+ throw new InvalidOperationException(
+ Localization.Text("L.Engine.VideoSourceFailed"));
+ }
+ return source;
+ }
+ finally
+ {
+ ObsNative.obs_data_release(settings);
+ }
+ }
+
private static nint CreateAudioSource(
string sourceId,
string name,
@@ -924,7 +1037,10 @@ private enum EncoderLoadProfile
private void CreateReplayBuffer()
{
- Directory.CreateDirectory(_config.OutputDirectory);
+ // Game target is discovered at runtime. Save to root first, then move
+ // completed file into game folder without copying across volumes.
+ string captureDirectory = _config.OutputDirectory;
+ Directory.CreateDirectory(captureDirectory);
bool opus = string.Equals(
_config.AudioCodec,
"opus",
@@ -940,7 +1056,7 @@ private void CreateReplayBuffer()
settings,
"max_size_mb",
Math.Max(0, _config.MaxReplaySizeMb));
- ObsNative.obs_data_set_string(settings, "directory", _config.OutputDirectory);
+ ObsNative.obs_data_set_string(settings, "directory", captureDirectory);
ObsNative.obs_data_set_string(
settings,
"format",
@@ -1183,16 +1299,30 @@ public void Dispose()
for (uint channel = 0; channel < 6; channel++)
ObsNative.obs_set_output_source(channel, 0);
- if (_videoSource != 0)
- ObsNative.obs_source_remove(_videoSource);
+ if (_automaticGameSourceShowing && _gameVideoSource != 0)
+ {
+ ObsNative.obs_source_dec_showing(_gameVideoSource);
+ _automaticGameSourceShowing = false;
+ }
+
+ if (_desktopVideoSource != 0)
+ ObsNative.obs_source_remove(_desktopVideoSource);
+ if (_gameVideoSource != 0)
+ ObsNative.obs_source_remove(_gameVideoSource);
foreach (nint source in _audioSources)
ObsNative.obs_source_remove(source);
- if (_videoSource != 0)
+ if (_desktopVideoSource != 0)
+ {
+ ObsNative.obs_source_release(_desktopVideoSource);
+ _desktopVideoSource = 0;
+ }
+ if (_gameVideoSource != 0)
{
- ObsNative.obs_source_release(_videoSource);
- _videoSource = 0;
+ ObsNative.obs_source_release(_gameVideoSource);
+ _gameVideoSource = 0;
}
+ _videoSource = 0;
foreach (nint source in _audioSources)
ObsNative.obs_source_release(source);
_audioSources.Clear();
@@ -1214,4 +1344,5 @@ public void Dispose()
public sealed record ReplaySaveOperation(
Task Completion,
- ulong InitialMuxBytes);
+ ulong InitialMuxBytes,
+ string? GameExecutable);
diff --git a/src/Captail/ReplayLibrary.cs b/src/Captail/ReplayLibrary.cs
new file mode 100644
index 0000000..de411fc
--- /dev/null
+++ b/src/Captail/ReplayLibrary.cs
@@ -0,0 +1,355 @@
+using System.Diagnostics;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.VisualBasic.FileIO;
+
+namespace Captail;
+
+public sealed record ReplayClip(
+ string Path,
+ string Name,
+ string? Collection,
+ DateTime SavedAt,
+ long SizeBytes,
+ TimeSpan Duration,
+ string? ThumbnailPath);
+
+public sealed class ReplayLibrary
+{
+ private static readonly HashSet VideoExtensions = new(
+ [".mp4", ".mkv", ".mov", ".webm"],
+ StringComparer.OrdinalIgnoreCase);
+ private readonly FfmpegAdapter _ffmpeg;
+ private readonly string _thumbnailDirectory;
+
+ public ReplayLibrary(FfmpegAdapter ffmpeg)
+ {
+ _ffmpeg = ffmpeg;
+ _thumbnailDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Captail",
+ "thumbnails");
+ }
+
+ public async Task> GetRecentAsync(
+ string rootDirectory,
+ int limit,
+ CancellationToken cancellationToken = default)
+ {
+ if (limit <= 0 || !Directory.Exists(rootDirectory))
+ return [];
+
+ string root = NormalizeRoot(rootDirectory);
+ List files;
+ try
+ {
+ var options = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.ReparsePoint,
+ };
+ files = Directory.EnumerateFiles(root, "*", options)
+ .Where(path => VideoExtensions.Contains(Path.GetExtension(path)))
+ .Select(path => new FileInfo(path))
+ .Where(file => file.Exists && file.Length > 0)
+ .OrderByDescending(file => file.LastWriteTimeUtc)
+ .Take(limit)
+ .ToList();
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ Log.Write($"Replay library scan failed: {exception.Message}");
+ return [];
+ }
+
+ var clips = new ReplayClip[files.Count];
+ await Parallel.ForEachAsync(
+ Enumerable.Range(0, files.Count),
+ new ParallelOptions
+ {
+ CancellationToken = cancellationToken,
+ MaxDegreeOfParallelism = 4,
+ },
+ async (index, token) =>
+ {
+ clips[index] = await LoadClipAsync(root, files[index], token);
+ });
+ return clips;
+ }
+
+ public void Reveal(string rootDirectory, ReplayClip clip)
+ {
+ string path = ValidateClipPath(rootDirectory, clip.Path);
+ if (!File.Exists(path))
+ throw new FileNotFoundException("Replay no longer exists.", path);
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = $"/select,\"{path}\"",
+ UseShellExecute = true,
+ });
+ }
+
+ public void DeleteToRecycleBin(string rootDirectory, ReplayClip clip)
+ {
+ string path = ValidateClipPath(rootDirectory, clip.Path);
+ if (!File.Exists(path))
+ return;
+ FileSystem.DeleteFile(
+ path,
+ UIOption.OnlyErrorDialogs,
+ RecycleOption.SendToRecycleBin,
+ UICancelOption.ThrowException);
+ DeleteThumbnail(clip.ThumbnailPath);
+ }
+
+ public async Task TrimAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ TimeSpan start,
+ TimeSpan end,
+ IReadOnlyList? audioStreamIndices = null,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ if (!File.Exists(source))
+ throw new FileNotFoundException("Replay no longer exists.", source);
+ if (start < TimeSpan.Zero || end > clip.Duration + TimeSpan.FromMilliseconds(250) || end <= start)
+ throw new ArgumentOutOfRangeException(nameof(start));
+
+ string directory = Path.GetDirectoryName(source)!;
+ string baseName = Path.GetFileNameWithoutExtension(source);
+ string extension = Path.GetExtension(source);
+ string destination = UniquePath(
+ directory,
+ $"{baseName}_trimmed_{DateTime.Now:HH-mm-ss}",
+ extension);
+ await _ffmpeg.TrimCopyAsync(
+ source,
+ destination,
+ start,
+ end,
+ audioStreamIndices,
+ cancellationToken);
+ return destination;
+ }
+
+ public async Task TrimOverwriteAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ TimeSpan start,
+ TimeSpan end,
+ IReadOnlyList? audioStreamIndices = null,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ if (!File.Exists(source))
+ throw new FileNotFoundException("Replay no longer exists.", source);
+ if (start < TimeSpan.Zero ||
+ end > clip.Duration + TimeSpan.FromMilliseconds(250) ||
+ end <= start)
+ {
+ throw new ArgumentOutOfRangeException(nameof(start));
+ }
+
+ await _ffmpeg.TrimOverwriteAsync(
+ source,
+ start,
+ end,
+ audioStreamIndices,
+ cancellationToken);
+ return source;
+ }
+
+ public Task> GetAudioTracksAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ return _ffmpeg.ReadAudioTracksAsync(source, cancellationToken);
+ }
+
+ public Task GetVideoInfoAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ return _ffmpeg.ReadVideoInfoAsync(source, cancellationToken);
+ }
+
+ public async Task GetAudioWaveformAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ AudioTrackInfo track,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ if (!_ffmpeg.IsAvailable || clip.Duration <= TimeSpan.Zero)
+ return null;
+
+ string identity =
+ $"{source}|{clip.SizeBytes}|{clip.SavedAt.ToUniversalTime().Ticks}|" +
+ $"audio:{track.StreamIndex}";
+ string hash = Convert.ToHexString(
+ SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
+ string path = Path.Combine(_thumbnailDirectory, $"{hash}_waveform.png");
+ if (!File.Exists(path))
+ {
+ await _ffmpeg.CreateWaveformAsync(
+ source,
+ path,
+ track.StreamIndex,
+ 1200,
+ 42,
+ cancellationToken);
+ }
+ return path;
+ }
+
+ public async Task> GetTimelineThumbnailsAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ int count,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ if (!_ffmpeg.IsAvailable || count <= 0 || clip.Duration <= TimeSpan.Zero)
+ return [];
+
+ string identity = $"{source}|{clip.SizeBytes}|{clip.SavedAt.ToUniversalTime().Ticks}";
+ string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
+ var thumbnails = new List(count);
+ for (int index = 0; index < count; index++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string path = Path.Combine(
+ _thumbnailDirectory,
+ $"{hash}_timeline_{index}.jpg");
+ if (!File.Exists(path))
+ {
+ double fraction = (index + 0.5) / count;
+ await _ffmpeg.CreateThumbnailAtAsync(
+ source,
+ path,
+ TimeSpan.FromSeconds(clip.Duration.TotalSeconds * fraction),
+ cancellationToken);
+ }
+ thumbnails.Add(path);
+ }
+ return thumbnails;
+ }
+
+ public async Task GetPreviewThumbnailAsync(
+ string rootDirectory,
+ ReplayClip clip,
+ TimeSpan position,
+ CancellationToken cancellationToken = default)
+ {
+ string source = ValidateClipPath(rootDirectory, clip.Path);
+ if (!_ffmpeg.IsAvailable || clip.Duration <= TimeSpan.Zero)
+ return clip.ThumbnailPath;
+
+ long bucket = Math.Max(0, (long)(position.TotalMilliseconds / 100) * 100);
+ string identity = $"{source}|{clip.SizeBytes}|{clip.SavedAt.ToUniversalTime().Ticks}|{bucket}";
+ string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
+ string path = Path.Combine(_thumbnailDirectory, $"{hash}_preview.jpg");
+ if (!File.Exists(path))
+ {
+ await _ffmpeg.CreateThumbnailAtAsync(
+ source,
+ path,
+ TimeSpan.FromMilliseconds(bucket),
+ cancellationToken);
+ }
+ return path;
+ }
+
+ private async Task LoadClipAsync(
+ string rootDirectory,
+ FileInfo file,
+ CancellationToken cancellationToken)
+ {
+ TimeSpan duration = TimeSpan.Zero;
+ string? thumbnail = null;
+ if (_ffmpeg.IsAvailable)
+ {
+ try
+ {
+ duration = await _ffmpeg.ReadDurationAsync(file.FullName, cancellationToken);
+ thumbnail = ThumbnailPath(file);
+ if (!File.Exists(thumbnail))
+ await _ffmpeg.CreateThumbnailAsync(file.FullName, thumbnail, duration, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Replay metadata failed ({file.Name}): {exception.Message}");
+ thumbnail = null;
+ }
+ }
+
+ string? collection = Path.GetRelativePath(rootDirectory, file.DirectoryName!);
+ if (collection == ".")
+ collection = null;
+ return new ReplayClip(
+ file.FullName,
+ file.Name,
+ collection,
+ file.LastWriteTime,
+ file.Length,
+ duration,
+ thumbnail);
+ }
+
+ private string ThumbnailPath(FileInfo file)
+ {
+ string key = $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}";
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key));
+ return Path.Combine(_thumbnailDirectory, Convert.ToHexString(hash) + ".jpg");
+ }
+
+ private static string ValidateClipPath(string rootDirectory, string clipPath)
+ {
+ string root = NormalizeRoot(rootDirectory);
+ string path = Path.GetFullPath(clipPath);
+ if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Replay path is outside configured library.");
+ if (!VideoExtensions.Contains(Path.GetExtension(path)))
+ throw new InvalidOperationException("Unsupported replay file type.");
+ return path;
+ }
+
+ private static string NormalizeRoot(string rootDirectory) =>
+ Path.GetFullPath(rootDirectory).TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
+
+ private static string UniquePath(string directory, string baseName, string extension)
+ {
+ string candidate = Path.Combine(directory, baseName + extension);
+ for (int suffix = 2; File.Exists(candidate); suffix++)
+ candidate = Path.Combine(directory, $"{baseName}_{suffix}{extension}");
+ return candidate;
+ }
+
+ private static void DeleteThumbnail(string? thumbnailPath)
+ {
+ try
+ {
+ if (thumbnailPath is not null && File.Exists(thumbnailPath))
+ File.Delete(thumbnailPath);
+ }
+ catch (IOException)
+ {
+ // Cache cleanup is best effort.
+ }
+ }
+}
diff --git a/src/Captail/ReplayPaths.cs b/src/Captail/ReplayPaths.cs
new file mode 100644
index 0000000..6d330de
--- /dev/null
+++ b/src/Captail/ReplayPaths.cs
@@ -0,0 +1,82 @@
+using System.IO;
+
+namespace Captail;
+
+public static class ReplayPaths
+{
+ private static readonly HashSet ReservedNames = new(
+ ["CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"],
+ StringComparer.OrdinalIgnoreCase);
+
+ public static string CaptureDirectory(Config config, string? gameExecutable)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ if (!config.OrganizeReplaysByGame ||
+ string.IsNullOrWhiteSpace(gameExecutable))
+ {
+ return config.OutputDirectory;
+ }
+
+ string name = Path.GetFileNameWithoutExtension(gameExecutable);
+ string safeName = SanitizeFolderName(name);
+ return Path.Combine(config.OutputDirectory, safeName);
+ }
+
+ public static string RouteSavedReplay(
+ Config config,
+ string savedPath,
+ string? gameExecutable)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ string source = Path.GetFullPath(savedPath);
+ string destinationDirectory = Path.GetFullPath(
+ CaptureDirectory(config, gameExecutable));
+ Directory.CreateDirectory(destinationDirectory);
+
+ string destination = Path.Combine(
+ destinationDirectory,
+ Path.GetFileName(source));
+ if (string.Equals(source, destination, StringComparison.OrdinalIgnoreCase))
+ return source;
+
+ destination = AvailableDestination(destination);
+ File.Move(source, destination);
+ return destination;
+ }
+
+ public static string SanitizeFolderName(string? value)
+ {
+ string name = value?.Trim() ?? "";
+ foreach (char invalid in Path.GetInvalidFileNameChars())
+ name = name.Replace(invalid, '_');
+
+ name = name.Trim(' ', '.');
+ if (name.Length > 80)
+ name = name[..80].TrimEnd(' ', '.');
+ if (name.Length == 0 || ReservedNames.Contains(name))
+ return "Game";
+ return name;
+ }
+
+ private static string AvailableDestination(string path)
+ {
+ if (!File.Exists(path))
+ return path;
+
+ string directory = Path.GetDirectoryName(path)!;
+ string name = Path.GetFileNameWithoutExtension(path);
+ string extension = Path.GetExtension(path);
+ for (int suffix = 2; suffix < 10_000; suffix++)
+ {
+ string candidate = Path.Combine(
+ directory,
+ $"{name}_{suffix}{extension}");
+ if (!File.Exists(candidate))
+ return candidate;
+ }
+
+ return Path.Combine(
+ directory,
+ $"{name}_{Guid.NewGuid():N}{extension}");
+ }
+}
diff --git a/src/Captail/SettingsWindow.xaml b/src/Captail/SettingsWindow.xaml
index a3d424f..6fab3b9 100644
--- a/src/Captail/SettingsWindow.xaml
+++ b/src/Captail/SettingsWindow.xaml
@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{DynamicResource L.Brand}"
Icon="Assets/Captail.ico"
- Width="404" Height="430"
+ Width="404" Height="650"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
ResizeMode="NoResize"
@@ -80,7 +80,12 @@
-
+
+
+
+
+
+
@@ -222,11 +227,158 @@
HorizontalAlignment="Left" Style="{StaticResource DashboardLinkButton}"
Click="OutputFolderSummary_Click" ToolTip="{DynamicResource L.OpenFolderSettings}">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
@@ -636,6 +772,20 @@
Click="BrowseOutput_Click"/>
+
+
+
+
+
+
+
+
+
+
+
+
@@ -755,6 +905,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Captail/SettingsWindow.xaml.cs b/src/Captail/SettingsWindow.xaml.cs
index f72ed84..1fe897e 100644
--- a/src/Captail/SettingsWindow.xaml.cs
+++ b/src/Captail/SettingsWindow.xaml.cs
@@ -5,6 +5,7 @@
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
+using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Captail.Interop;
@@ -12,7 +13,7 @@ namespace Captail;
public partial class SettingsWindow : Window
{
- private const double DashboardHeight = 430;
+ private const double DashboardHeight = 650;
private readonly Config _config;
private readonly Action _saveReplay;
@@ -29,6 +30,7 @@ private readonly Func<
private EncoderCapabilities _capabilities;
private readonly DispatcherTimer _diskTimer;
private readonly CancellationTokenSource _lifetimeCts = new();
+ private readonly ReplayLibrary _replayLibrary;
private string _outputDirectory;
private string _pendingSaveHotkey;
private string _pendingToggleHotkey;
@@ -38,7 +40,6 @@ private readonly Func<
private int _availableReplaySeconds;
private bool? _animatedRecordingState;
private int _deviceRefreshVersion;
- private int _processRefreshVersion;
private int _diskRefreshInProgress;
private int _actionInProgress;
private UpdateRelease? _availableUpdate;
@@ -46,6 +47,8 @@ private readonly Func<
private int _updateProgress;
private bool _updateCheckInProgress;
private bool _updateInstallInProgress;
+ private int _libraryRefreshInProgress;
+ private ReplayClip? _pendingDeleteClip;
public bool Applied { get; private set; }
@@ -78,6 +81,7 @@ internal SettingsWindow(
_runtimeActive = runtimeActive;
InitializeComponent();
+ _replayLibrary = new ReplayLibrary(new FfmpegAdapter());
ApplyHardwareCapabilities();
_diskTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_diskTimer.Tick += async (_, _) => await RefreshDiskAsync();
@@ -100,8 +104,8 @@ await RunUiActionAsync(async () =>
{
await Task.WhenAll(
LoadDeviceListsAsync(),
- PopulateGameProcessesAsync(),
- RefreshDiskAsync());
+ RefreshDiskAsync(),
+ RefreshReplayLibraryAsync());
});
_ = CheckForUpdatesAsync(force: false);
};
@@ -295,121 +299,16 @@ private static DeviceListsSnapshot CollectDeviceLists()
return new DeviceListsSnapshot(renderDevices, captureDevices, monitors);
}
- private async Task PopulateGameProcessesAsync()
- {
- int version = Interlocked.Increment(ref _processRefreshVersion);
- string selectedPath = GetSelectedTag(GameProcessBox, _config.GameExecutablePath);
- IReadOnlyList<(string Path, string Label)> choices;
- try
- {
- choices = await Task.Run(CollectGameProcesses, _lifetimeCts.Token);
- }
- catch (OperationCanceledException)
- {
- return;
- }
- catch (Exception exception)
- {
- Log.Write($"Game-process list unavailable: {exception.Message}");
- return;
- }
-
- if (version != _processRefreshVersion || _lifetimeCts.IsCancellationRequested)
- return;
-
- var visibleChoices = choices.ToDictionary(
- choice => choice.Path,
- choice => choice.Label,
- StringComparer.OrdinalIgnoreCase);
- if (!string.IsNullOrWhiteSpace(selectedPath) &&
- !visibleChoices.ContainsKey(selectedPath))
- {
- visibleChoices[selectedPath] = Localization.Format(
- "L.Video.GameNotRunning",
- Path.GetFileName(selectedPath));
- }
-
- GameProcessBox.Items.Clear();
- GameProcessBox.Items.Add(new ComboBoxItem
- {
- Tag = "",
- Content = Localization.Text("L.Video.ChooseGame"),
- IsEnabled = false,
- });
- foreach ((string path, string label) in visibleChoices.OrderBy(pair => pair.Value))
- GameProcessBox.Items.Add(new ComboBoxItem { Tag = path, Content = label });
- SelectByTag(GameProcessBox, selectedPath);
- }
-
- private static IReadOnlyList<(string Path, string Label)> CollectGameProcesses()
- {
- var choices = new Dictionary(StringComparer.OrdinalIgnoreCase);
- HashSet shellProcesses = new(StringComparer.OrdinalIgnoreCase)
- {
- "ApplicationFrameHost",
- "codex-computer-use",
- "explorer",
- "GameBar",
- "GameBarFTServer",
- "LockApp",
- "RuntimeBroker",
- "SearchHost",
- "ShellExperienceHost",
- "StartMenuExperienceHost",
- "SystemSettings",
- "svchost",
- "Taskmgr",
- "TextInputHost",
- };
-
- foreach (Process process in Process.GetProcesses())
- {
- try
- {
- if (process.Id == Environment.ProcessId || process.MainWindowHandle == 0)
- continue;
-
- string? path = process.MainModule?.FileName;
- if (string.IsNullOrWhiteSpace(path))
- continue;
- if (shellProcesses.Contains(Path.GetFileNameWithoutExtension(path)))
- continue;
-
- string title = string.IsNullOrWhiteSpace(process.MainWindowTitle)
- ? Path.GetFileNameWithoutExtension(path)
- : process.MainWindowTitle;
- choices[path] = $"{Path.GetFileName(path)} · {title}";
- }
- catch
- {
- // System and protected processes are inaccessible; omit them.
- }
- finally
- {
- process.Dispose();
- }
- }
-
- return choices
- .OrderBy(pair => pair.Value)
- .Select(pair => (pair.Key, pair.Value))
- .ToArray();
- }
-
private void CaptureSourceBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (IsInitialized)
UpdateCaptureSourceState();
}
- private async void GameProcessBox_DropDownOpened(object sender, EventArgs e) =>
- await RunUiActionAsync(PopulateGameProcessesAsync);
-
private void UpdateCaptureSourceState()
{
bool game = GetSelectedTag(CaptureSourceBox, "desktop") == "game";
- GameProcessRow.Visibility = game ? Visibility.Visible : Visibility.Collapsed;
- MonitorBox.IsEnabled = !game;
+ MonitorBox.IsEnabled = true;
SystemAudioLabel.Text = Localization.Text(
game ? "L.Audio.GameAudio" : "L.Audio.SystemAudio");
AudioTrackHintText.Text = Localization.Text(
@@ -426,7 +325,6 @@ private void LoadSettingsControls()
SelectByTag(ReplaySizeLimitBox, _config.MaxReplaySizeMb.ToString());
SelectRadioByTag(FpsOptions, _config.FrameRate.ToString());
SelectByTag(CaptureSourceBox, _config.CaptureSource);
- SelectByTag(GameProcessBox, _config.GameExecutablePath);
SelectByTag(CodecBox, _config.Codec);
SelectByTag(BitrateBox, _config.BitrateMbps.ToString());
SelectByTag(MonitorBox, _config.MonitorIndex.ToString());
@@ -443,6 +341,7 @@ private void LoadSettingsControls()
MicVolumeSlider.Value = Math.Clamp(_config.MicrophoneVolume, 0, 100);
MicBoostSlider.Value = Math.Clamp(_config.MicrophoneBoostDb, 0, 20);
AutostartBox.IsChecked = Autostart.IsEnabled();
+ OrganizeByGameBox.IsChecked = _config.OrganizeReplaysByGame;
_pendingSaveHotkey = _config.Hotkey;
_pendingToggleHotkey = _config.ToggleReplayHotkey;
@@ -587,14 +486,13 @@ private void OnLanguageChanged()
return;
}
- _ = RunUiActionAsync(async () => await Task.WhenAll(
- LoadDeviceListsAsync(),
- PopulateGameProcessesAsync()));
+ _ = RunUiActionAsync(LoadDeviceListsAsync);
ApplyHardwareCapabilities();
UpdateCaptureSourceState();
UpdateRuntimeState(_runtimeActive);
RenderUpdateStatus();
_ = RefreshDiskAsync();
+ _ = RefreshReplayLibraryAsync();
}
private void GitHub_Click(object sender, RoutedEventArgs e)
@@ -839,11 +737,13 @@ private void ShowDashboard()
DoneButton.Visibility = Visibility.Collapsed;
SetWindowHeight(DashboardHeight);
UpdateRuntimeState(_runtimeActive);
+ _ = RefreshReplayLibraryAsync();
AnimateView(DashboardPanel);
}
private void SetWindowHeight(double height)
{
+ height = Math.Min(height, Math.Max(430, SystemParameters.WorkArea.Height - 32));
double centerY = Top + ActualHeight / 2;
Height = height;
Top = Math.Clamp(centerY - height / 2, SystemParameters.WorkArea.Top + 16,
@@ -1058,6 +958,12 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (_capturingHotkeyButton is null)
{
+ if (e.Key == Key.Escape && DeleteConfirmOverlay.Visibility == Visibility.Visible)
+ {
+ CancelDeleteReplay();
+ e.Handled = true;
+ return;
+ }
if (e.Key == Key.Escape && SettingsPanel.Visibility == Visibility.Visible)
{
LoadSettingsControls();
@@ -1153,15 +1059,6 @@ private async void Apply_Click(object sender, RoutedEventArgs e)
candidate.BufferSeconds = GetSelectedRadioInt(BufferOptions, _config.BufferSeconds);
candidate.MaxReplaySizeMb = GetSelectedInt(ReplaySizeLimitBox, 0);
candidate.CaptureSource = GetSelectedTag(CaptureSourceBox, "desktop");
- candidate.GameExecutablePath = GetSelectedTag(GameProcessBox, "");
- if (candidate.CaptureSource == "game" &&
- string.IsNullOrWhiteSpace(candidate.GameExecutablePath))
- {
- ShowError(
- Localization.Text("L.Error.GameTitle"),
- Localization.Text("L.Error.GameMessage"));
- return;
- }
string selectedCodec = GetSelectedTag(CodecBox, _config.Codec);
if (!_capabilities.Supports(selectedCodec))
{
@@ -1190,6 +1087,7 @@ private async void Apply_Click(object sender, RoutedEventArgs e)
candidate.AudioCodec = GetSelectedTag(AudioCodecBox, "aac");
candidate.SeparateAudioTracks = separateAudioTracks;
candidate.OutputDirectory = _outputDirectory;
+ candidate.OrganizeReplaysByGame = OrganizeByGameBox.IsChecked == true;
candidate.Hotkey = _pendingSaveHotkey;
candidate.ToggleReplayHotkey = _pendingToggleHotkey;
candidate.Normalize();
@@ -1316,6 +1214,165 @@ private async Task RefreshDiskAsync()
}
}
+ public void NotifyReplaySaved(string path)
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(() => NotifyReplaySaved(path));
+ return;
+ }
+
+ _ = RefreshReplayLibraryAsync();
+ }
+
+ private async void RefreshLibrary_Click(object sender, RoutedEventArgs e) =>
+ await RunUiActionAsync(RefreshReplayLibraryAsync);
+
+ private async Task RefreshReplayLibraryAsync()
+ {
+ if (Interlocked.Exchange(ref _libraryRefreshInProgress, 1) != 0)
+ return;
+ try
+ {
+ IReadOnlyList clips = await _replayLibrary.GetRecentAsync(
+ _outputDirectory,
+ int.MaxValue,
+ _lifetimeCts.Token);
+ if (_lifetimeCts.IsCancellationRequested)
+ return;
+
+ RecentReplaysList.ItemsSource = clips.Select(CreateReplayClipItem).ToArray();
+ ReplayLibraryEmptyText.Visibility = clips.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Replay library refresh failed: {exception}");
+ RecentReplaysList.ItemsSource = null;
+ ReplayLibraryEmptyText.Visibility = Visibility.Visible;
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _libraryRefreshInProgress, 0);
+ }
+ }
+
+ private ReplayClipItem CreateReplayClipItem(ReplayClip clip)
+ {
+ BitmapImage? thumbnail = null;
+ if (clip.ThumbnailPath is not null && File.Exists(clip.ThumbnailPath))
+ {
+ try
+ {
+ thumbnail = new BitmapImage();
+ thumbnail.BeginInit();
+ thumbnail.CacheOption = BitmapCacheOption.OnLoad;
+ thumbnail.UriSource = new Uri(clip.ThumbnailPath);
+ thumbnail.EndInit();
+ thumbnail.Freeze();
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Replay thumbnail load failed: {exception.Message}");
+ }
+ }
+
+ string saved = clip.SavedAt.ToString("g");
+ string metadata = Localization.Format(
+ "L.Library.Metadata",
+ saved,
+ FormatFileSize(clip.SizeBytes));
+ if (!string.IsNullOrWhiteSpace(clip.Collection))
+ metadata = $"{clip.Collection} · {metadata}";
+ return new ReplayClipItem(
+ clip,
+ Path.GetFileNameWithoutExtension(clip.Name),
+ metadata,
+ FormatTimelineDuration(clip.Duration),
+ thumbnail,
+ clip.Duration > TimeSpan.FromMilliseconds(200));
+ }
+
+ private void RevealReplay_Click(object sender, RoutedEventArgs e)
+ {
+ if (((FrameworkElement)sender).DataContext is not ReplayClipItem item)
+ return;
+ try
+ {
+ _replayLibrary.Reveal(_outputDirectory, item.Clip);
+ }
+ catch (Exception exception)
+ {
+ HandleUiActionError("Reveal replay", exception);
+ }
+ }
+
+ private void TrimReplay_Click(object sender, RoutedEventArgs e)
+ {
+ if (((FrameworkElement)sender).DataContext is not ReplayClipItem item || !item.CanTrim)
+ return;
+ var editor = new ClipEditorWindow(
+ _replayLibrary,
+ _outputDirectory,
+ item.Clip,
+ savedPath =>
+ {
+ Log.Write($"Trimmed replay saved: {savedPath}");
+ _ = RefreshReplayLibraryAsync();
+ })
+ {
+ Owner = this,
+ };
+ editor.ShowDialog();
+ }
+
+ private void RequestDeleteReplay_Click(object sender, RoutedEventArgs e)
+ {
+ if (((FrameworkElement)sender).DataContext is not ReplayClipItem item)
+ return;
+ _pendingDeleteClip = item.Clip;
+ DeleteConfirmFileText.Text = item.Clip.Name;
+ DeleteConfirmOverlay.Visibility = Visibility.Visible;
+ AnimateView(DeleteConfirmOverlay);
+ }
+
+ private void CancelDeleteReplay_Click(object sender, RoutedEventArgs e) =>
+ CancelDeleteReplay();
+
+ private void CancelDeleteReplay()
+ {
+ _pendingDeleteClip = null;
+ DeleteConfirmOverlay.Visibility = Visibility.Collapsed;
+ }
+
+ private async void ConfirmDeleteReplay_Click(object sender, RoutedEventArgs e)
+ {
+ ReplayClip? clip = _pendingDeleteClip;
+ CancelDeleteReplay();
+ if (clip is null)
+ return;
+ try
+ {
+ await Task.Run(
+ () => _replayLibrary.DeleteToRecycleBin(_outputDirectory, clip),
+ _lifetimeCts.Token);
+ await RefreshReplayLibraryAsync();
+ }
+ catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ HandleUiActionError("Delete replay", exception);
+ }
+ }
+
private static DiskSnapshot ReadDiskSnapshot(string outputDirectory)
{
string? root = Path.GetPathRoot(Path.GetFullPath(outputDirectory));
@@ -1484,11 +1541,18 @@ private static string FormatResolution(string resolution) =>
_ => Localization.Text("L.Video.SourceShort"),
};
- private string LocalizedCaptureSource(string? _) =>
- Localization.Text(
+ private string LocalizedCaptureSource(string? activeCaptureSource)
+ {
+ if (!string.IsNullOrWhiteSpace(activeCaptureSource))
+ {
+ return activeCaptureSource;
+ }
+
+ return Localization.Text(
_config.CaptureSource == "game"
? "L.Video.GameLower"
: "L.Video.DesktopLower");
+ }
private enum UpdateDisplayState
{
@@ -1568,5 +1632,31 @@ private static string FormatBytes(long bytes)
return Localization.Format("L.Unit.GB", value);
}
+ private static string FormatFileSize(long bytes)
+ {
+ string[] units = ["B", "KB", "MB", "GB"];
+ double value = Math.Max(0, bytes);
+ int unit = 0;
+ while (value >= 1024 && unit < units.Length - 1)
+ {
+ value /= 1024;
+ unit++;
+ }
+ return $"{value:0.#} {units[unit]}";
+ }
+
+ private static string FormatTimelineDuration(TimeSpan duration) =>
+ duration.TotalHours >= 1
+ ? duration.ToString(@"h\:mm\:ss")
+ : duration.ToString(@"m\:ss");
+
+ private sealed record ReplayClipItem(
+ ReplayClip Clip,
+ string Title,
+ string Details,
+ string Duration,
+ BitmapImage? Thumbnail,
+ bool CanTrim);
+
private Brush FindBrush(string key) => (Brush)FindResource(key);
}
diff --git a/src/Captail/Themes/Theme.xaml b/src/Captail/Themes/Theme.xaml
index 3d2378f..0a8a5ae 100644
--- a/src/Captail/Themes/Theme.xaml
+++ b/src/Captail/Themes/Theme.xaml
@@ -80,6 +80,10 @@
M2 4h20v13H2z M8 21h8 M12 17v4
M9 2h6v12H9z M5 10v1a7 7 0 0 0 14 0v-1 M12 18v4
M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z
+ M6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M8.6 8.6L21 21 M8.6 15.4L21 3
+ M3 6h18 M8 6V3h8v3 M19 6l-1 15H6L5 6 M10 11v6 M14 11v6
+ M8 5v14l11-7z
+ M8 5h3v14H8z M14 5h3v14h-3z
M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.69c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 7.7a9.6 9.6 0 0 1 2.5.34c1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.86V21c0 .27.18.58.69.48A10 10 0 0 0 12 2z
@@ -296,6 +300,13 @@
+
+