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

- Captail main window showing replay status, audio sources, recording format, disk space, and save hotkey + Captail main window showing replay status, audio sources, recording format, disk space, and the scrollable replay library

@@ -24,16 +24,21 @@ Captail is designed around one rule: **Instant Replay should stay on.** A game c Captail audio, storage, and hotkey settings

+

+ Captail clip editor with video timeline, separate audio tracks, trim range, and live media details +

+ > [!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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - @@ -636,6 +772,20 @@ Click="BrowseOutput_Click"/> + + + + + + + + + + + + @@ -755,6 +905,27 @@ + + + + + + + + +