diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cf65a..5f9f15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable user-facing changes are documented here. ## [Unreleased] +## [0.2.1] - 2026-08-21 + +### Added + +- **Replay player:** Open a recent replay for immediate playback without entering edit mode, then continue into Trim when needed. The player supports seeking, fullscreen playback, keyboard controls, and temporary speed feedback from `0.25×` to `2×`. +- **Replay-off game reminder:** An optional notification warns once when Captail detects a game while Instant Replay is disabled. It never starts recording without user action. +- **Unsaved-settings guard:** Editing settings reveals a compact notice with Cancel and Done actions in the title bar. Close, `Alt+F4`, and `Esc` attempts keep the settings open and use a short shake to draw attention to pending changes. + +### Improved + +- **Lower Game Capture idle load:** When no plausible game is running, Captail releases the replay output and keeps only a low-rate detector active. The normal replay pipeline resumes automatically after a game is detected. +- **Cleaner secondary actions:** GitHub, bug reports, feature requests, and privacy information now live in one compact About menu while the version and update state remain directly visible. +- **Replay status indicator:** Removed the dark halo and made the indicator fully opaque for consistent contrast on bright backgrounds. + +### Fixed + +- Prevented Game Capture from treating unrelated fullscreen applications as games while waiting for a real game candidate. +- Generated unplated Microsoft Store taskbar icons at every required target size and added package validation so placeholder-sized icons cannot ship unnoticed. +- Prevented changed settings from being lost when the settings window is dismissed accidentally. + ## [0.2.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 3004f94..b39784a 100644 --- a/README.md +++ b/README.md @@ -30,18 +30,16 @@ Captail is a focused alternative to NVIDIA ShadowPlay Instant Replay. It keeps the latest seconds or minutes in a rolling buffer, then saves them when you press a hotkey. No scenes, streaming setup, account, cloud upload, analytics, or telemetry. > [!WARNING] -> Captail `v0.2.0` is an early public preview. Recording works, but bugs and hardware-specific problems are expected. NVIDIA RTX 40 and RTX 50 series are tested; other GPUs need broader public testing. +> Captail `v0.2.1` is an early public preview. Recording works, but bugs and hardware-specific problems are expected. NVIDIA RTX 40 and RTX 50 series are tested; other GPUs need broader public testing. -## What's new in Captail 0.2.0? +## What's new in Captail 0.2.1? -- Use Captail in 11 languages through a compact native-name language menu. First launch follows the Windows language when supported. -- Identify matching physical displays directly from the monitor selector. -- Keep Desktop capture active when a fullscreen game is recognized but cannot provide usable Game Capture frames. -- Send a useful bug report with system and recording details plus a short sanitized log excerpt already filled in. -- Suggest focused improvements through the new Feature link beside Report bug. -- Keep the replay indicator in the correct game or desktop corner without covering the taskbar or newer system overlays. - -Simplified Chinese support was contributed by [@zhuyouyi](https://github.com/zhuyouyi) through [PR #24](https://github.com/FaulMit/captail/pull/24). +- Open any recent replay in a fast built-in player, seek through it, change playback speed with the arrow keys, switch to fullscreen, or continue directly into Trim. +- Let Game Capture sleep when no plausible game is running, reducing idle GPU and memory use while keeping a lightweight detector ready. +- Receive an optional reminder when a game starts while Instant Replay is disabled. +- Keep accidental settings changes under control with clear Cancel and Done actions. Closing settings with pending edits now shakes the window instead of silently losing them. +- Access GitHub, bug reports, feature requests, and privacy information from one compact About menu. +- Get a cleaner replay indicator and correctly sized Microsoft Store taskbar icons. ## Is Captail for me? @@ -71,6 +69,10 @@ Captail is not a streaming application, scene compositor, DRM bypass, or cloud c Captail audio settings with system audio, microphone, volume controls, microphone boost, and separate tracks

+

+ Captail replay player showing a real AV1 4K 240 FPS replay with seek, playback, trim, and fullscreen controls +

+

Captail clip editor showing AV1 3840 by 2160 video at 240 FPS, trim timeline, and separate system and microphone audio tracks

@@ -114,6 +116,8 @@ Both hotkeys are configurable. Double-click the tray icon to reopen Captail. The optional replay status indicator keeps recording state visible over games and desktop apps. It is enabled by default, can sit in any screen corner, ignores mouse input, and briefly confirms a successful save. Disable it or change its position in Replay settings. +Enable **Warn when a game starts** if you want one quiet reminder when Captail detects a game while Instant Replay is disabled. The reminder never enables recording automatically. + On first launch, Captail follows your Windows language when it supports it; otherwise it starts in English. Use the compact language control in the title bar to switch between English, Russian, Ukrainian, Simplified Chinese, Spanish, Brazilian Portuguese, German, French, Japanese, Korean, and Polish. If similarly sized monitors are hard to distinguish, open Video settings and select **Identify displays** beside the monitor list. Captail briefly places a large number on each matching screen. @@ -123,9 +127,9 @@ If similarly sized monitors are hard to distinguish, open Video settings and sel | Mode | When no game is detected | When a fullscreen game appears | Audio source | | --- | --- | --- | --- | | **Desktop** | Records the selected monitor | Uses direct Game Capture when usable; otherwise keeps desktop video active while recognizing supported fullscreen games | Selected Windows output device | -| **Game Capture** | Waits without recording the desktop | Captures the detected game directly | Detected game audio | +| **Game Capture** | Keeps a lightweight game detector ready while the replay buffer sleeps | Wakes the replay buffer and captures the detected game directly | Detected game audio | -The replay buffer stays armed in both modes. Use **Desktop** when you want continuous coverage; use **Game Capture** when privacy outside games matters more. When a fullscreen game cannot be hooked, Desktop mode favors uninterrupted video over pretending direct Game Capture is working. +Use **Desktop** when you want continuous coverage; use **Game Capture** when privacy and low idle resource use outside games matter more. In Game Capture mode, Captail wakes the replay buffer only after a plausible game appears. When a fullscreen game cannot be hooked, Desktop mode favors uninterrupted video over pretending direct Game Capture is working. ## What happens when capture breaks? @@ -193,6 +197,8 @@ Yes. The main window lists supported files from the selected replay folder in a - Open a replay in File Explorer. - Move it to the Recycle Bin after confirmation. +- Play it immediately without entering edit mode. +- Seek, pause, use fullscreen, and change playback speed from `0.25×` to `2×` with the keyboard. - Scrub through a responsive preview. - Select one trim range with draggable handles. - Keep or remove each audio track. @@ -273,7 +279,7 @@ Review logs before attaching them and remove personal paths or other sensitive i Compatibility reports for older NVIDIA, AMD, and Intel GPUs are useful even when everything works. -Have an improvement rather than a bug? Use the **Feature** footer action to open Captail's focused feature-request template. +Have an improvement rather than a bug? Open **About → Feature** to use Captail's focused feature-request template. ## How do I build or contribute? diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md index ecfad33..618021a 100644 --- a/docs/SCREENSHOTS.md +++ b/docs/SCREENSHOTS.md @@ -11,6 +11,7 @@ Every release must ship a fresh screenshot set captured from the exact release-c | `docs/captail-main.png` | Instant Replay active | Complete main window showing `AV1 · 4K · 240 FPS`, enabled system and microphone audio, current version, Save button, and several real recent-replay rows. | | `docs/captail-settings-video.png` | Video settings | Hardware AV1 selected at `3840 × 2160` and `240 FPS`, plus source, bitrate, quality profile, display, and resolution controls. | | `docs/captail-settings-audio.png` | Audio and replay settings | Enabled system audio and microphone, separate tracks, volume, microphone boost, buffer, storage, and hotkey controls where they fit naturally. Use a second real settings screenshot when one readable frame cannot contain them. | +| `docs/captail-player.png` | Real replay loaded in Preview | Working AV1 4K 240 FPS playback, seek bar, keyboard help, Trim action, fullscreen control, and matching media details. | | `docs/captail-editor.png` | Real replay loaded | Working preview of a real AV1 4K 240 FPS replay, selected trim range, video timeline, available audio tracks, matching media details, and save actions. | The next refresh should migrate the two older settings JPEG files to the stable PNG names above. After that migration, overwrite the stable files on every release instead of adding versioned duplicates. diff --git a/docs/captail-editor.png b/docs/captail-editor.png index 701a387..e4c83a2 100644 Binary files a/docs/captail-editor.png and b/docs/captail-editor.png differ diff --git a/docs/captail-main.png b/docs/captail-main.png index 108194b..3ed64ad 100644 Binary files a/docs/captail-main.png and b/docs/captail-main.png differ diff --git a/docs/captail-player.png b/docs/captail-player.png new file mode 100644 index 0000000..c46a6ac Binary files /dev/null and b/docs/captail-player.png differ diff --git a/docs/captail-settings-audio.png b/docs/captail-settings-audio.png index 2ffceff..1c000e8 100644 Binary files a/docs/captail-settings-audio.png and b/docs/captail-settings-audio.png differ diff --git a/docs/captail-settings-video.png b/docs/captail-settings-video.png index e55c120..5ff7d76 100644 Binary files a/docs/captail-settings-video.png and b/docs/captail-settings-video.png differ diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs index b70893e..cf73feb 100644 --- a/src/Captail/App.xaml.cs +++ b/src/Captail/App.xaml.cs @@ -37,6 +37,10 @@ public partial class App : Application private int _recoveryFailures; private int _recoveryInProgress; private int _captureStateRefreshInProgress; + private string _pendingReplayOffGame = ""; + private int _pendingReplayOffGameSamples; + private readonly HashSet _warnedReplayOffGames = + new(StringComparer.OrdinalIgnoreCase); private OverlayNotificationWindow? _overlayNotification; private ReplayStatusIndicatorWindow? _recordingIndicator; private readonly UpdateService _updateService = new(); @@ -78,6 +82,9 @@ protected override async void OnStartup(StartupEventArgs e) argument => argument.StartsWith( "--qa-game-capture=", StringComparison.OrdinalIgnoreCase)); + bool gameCaptureIdleTest = e.Args.Contains( + "--qa-game-capture-idle", + StringComparer.OrdinalIgnoreCase); bool replaySegmentsTest = e.Args.Contains( "--qa-replay-segments", StringComparer.OrdinalIgnoreCase); @@ -116,6 +123,13 @@ protected override async void OnStartup(StartupEventArgs e) StringComparison.OrdinalIgnoreCase)) ?["--qa-clip-editor=".Length..]; bool clipEditorTest = !string.IsNullOrWhiteSpace(clipEditorTestPath); + string? replayPlayerTestPath = e.Args + .FirstOrDefault(argument => argument.StartsWith( + "--qa-replay-player=", + StringComparison.OrdinalIgnoreCase)) + ?["--qa-replay-player=".Length..]; + bool replayPlayerTest = + !string.IsNullOrWhiteSpace(replayPlayerTestPath); string? audioMixTestPath = e.Args .FirstOrDefault(argument => argument.StartsWith( "--qa-audio-mix=", @@ -144,6 +158,7 @@ protected override async void OnStartup(StartupEventArgs e) const bool codecTest = false; const bool capabilityModelTest = false; const bool gameCaptureTest = false; + const bool gameCaptureIdleTest = false; const bool replaySegmentsTest = false; const bool fileRetryTest = false; const bool automaticCapturePolicyTest = false; @@ -151,6 +166,7 @@ protected override async void OnStartup(StartupEventArgs e) const bool localizationTest = false; const bool updateCheckTest = false; const bool clipEditorTest = false; + const bool replayPlayerTest = false; const bool audioMixTest = false; const bool previewGeometryTest = false; const bool trimOverwriteTest = false; @@ -166,8 +182,9 @@ protected override async void OnStartup(StartupEventArgs e) backgroundLaunch, shutdownExisting, _uiOnly || faultTest || codecTest || capabilityModelTest || - gameCaptureTest || replaySegmentsTest || updateCheckTest || - clipEditorTest || audioMixTest || previewGeometryTest || + gameCaptureTest || gameCaptureIdleTest || + replaySegmentsTest || updateCheckTest || + clipEditorTest || replayPlayerTest || audioMixTest || previewGeometryTest || fileRetryTest || trimOverwriteTest || automaticCapturePolicyTest || replayRoutingTest || localizationTest)) @@ -222,6 +239,11 @@ protected override async void OnStartup(StartupEventArgs e) RunGameCaptureTest(e.Args); return; } + if (gameCaptureIdleTest) + { + RunGameCaptureIdleTest(); + return; + } if (replaySegmentsTest) { RunReplaySegmentsTest(); @@ -260,6 +282,13 @@ await _updateService.CheckAsync( await RunClipEditorTestAsync(clipEditorTestPath!); return; } + if (replayPlayerTest) + { + await RunClipEditorTestAsync( + replayPlayerTestPath!, + ClipWindowMode.Preview); + return; + } if (audioMixTest) { await RunAudioMixTestAsync(audioMixTestPath!); @@ -414,6 +443,7 @@ private void RunAutomaticCapturePolicyTest() "firefox.exe", "explorer.exe", "dwm.exe", "Spotify.exe", "vlc.exe", "mpv.exe", "obs64.exe", "Captail.exe", "ApplicationFrameHost.exe", "ShellExperienceHost.exe", "SearchHost.exe", + "ScreenClippingHost.exe", "SnippingTool.exe", ]; string[] accepted = [ @@ -460,6 +490,22 @@ private void RunAutomaticCapturePolicyTest() "RenderTool.exe", @"C:\Tools\RenderTool.exe", isFullscreen: true); + bool fullscreenGameWakesDetector = ObsReplayEngine.ShouldWakeGameCapture( + new Interop.CaptureInterop.ForegroundAppInfo( + "ExampleGame.exe", + @"C:\Games\ExampleGame.exe", + true)); + bool windowedSteamGameWakesDetector = ObsReplayEngine.ShouldWakeGameCapture( + new Interop.CaptureInterop.ForegroundAppInfo( + "cs2.exe", + @"D:\SteamLibrary\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe", + false)); + bool fullscreenTelegramDoesNotWakeDetector = + !ObsReplayEngine.ShouldWakeGameCapture( + new Interop.CaptureInterop.ForegroundAppInfo( + "Telegram.exe", + @"C:\Apps\Telegram.exe", + true)); bool passed = falsePositives.Length == 0 && falseNegatives.Length == 0 && !ObsReplayEngine.IsAutomaticCaptureCandidate("") && @@ -467,7 +513,10 @@ private void RunAutomaticCapturePolicyTest() missingVideoRejected && blockedSteamGameAccepted && windowedSteamGameRejected && fullscreenTelegramRejected && - unknownFullscreenAppRejected; + unknownFullscreenAppRejected && + fullscreenGameWakesDetector && + windowedSteamGameWakesDetector && + fullscreenTelegramDoesNotWakeDetector; Log.Write( $"AUTO_CAPTURE_POLICY_TEST {(passed ? "PASS" : "FAIL")}: " + $"falsePositives={string.Join(',', falsePositives)}, " + @@ -478,7 +527,10 @@ private void RunAutomaticCapturePolicyTest() $"blockedSteamGameAccepted={blockedSteamGameAccepted}, " + $"windowedSteamGameRejected={windowedSteamGameRejected}, " + $"fullscreenTelegramRejected={fullscreenTelegramRejected}, " + - $"unknownFullscreenAppRejected={unknownFullscreenAppRejected}"); + $"unknownFullscreenAppRejected={unknownFullscreenAppRejected}, " + + $"fullscreenGameWakesDetector={fullscreenGameWakesDetector}, " + + $"windowedSteamGameWakesDetector={windowedSteamGameWakesDetector}, " + + $"fullscreenTelegramDoesNotWakeDetector={fullscreenTelegramDoesNotWakeDetector}"); Shutdown(passed ? 0 : 22); } @@ -680,7 +732,9 @@ mixedVideo is not null && } } - private async Task RunClipEditorTestAsync(string path) + private async Task RunClipEditorTestAsync( + string path, + ClipWindowMode mode = ClipWindowMode.Trim) { string fullPath = Path.GetFullPath(path); if (!File.Exists(fullPath)) @@ -701,7 +755,8 @@ private async Task RunClipEditorTestAsync(string path) new ReplayLibrary(ffmpeg), file.DirectoryName!, clip, - saved => Log.Write($"CLIP_EDITOR_QA saved={saved}")); + saved => Log.Write($"CLIP_EDITOR_QA saved={saved}"), + mode); MainWindow = window; window.ShowDialog(); Shutdown(0); @@ -956,31 +1011,38 @@ private async void RunGameCaptureTest(string[] args) }; if (!TryStartPipeline(showError: false)) throw new InvalidOperationException("OBS Game Capture did not start."); + ObsReplayEngine engine = _obs ?? throw new InvalidOperationException( + "OBS Game Capture engine is missing."); DateTime hookDeadline = DateTime.UtcNow.AddSeconds(8); - while (!_obs!.IsGameHooked && DateTime.UtcNow < hookDeadline) + while (DateTime.UtcNow < hookDeadline) + { + engine.RefreshCaptureState(); + if (engine.IsGameHooked && engine.IsActive) + break; await Task.Delay(100); - if (_obs.IsGameHooked) + } + if (engine.IsGameHooked) await Task.Delay(TimeSpan.FromSeconds(2)); - uint totalBefore = _obs.TotalRenderedFrames; - uint laggedBefore = _obs.LaggedRenderedFrames; + uint totalBefore = engine.TotalRenderedFrames; + uint laggedBefore = engine.LaggedRenderedFrames; await Task.Delay(TimeSpan.FromSeconds(6)); - uint totalAfter = _obs.TotalRenderedFrames; - uint laggedAfter = _obs.LaggedRenderedFrames; + uint totalAfter = engine.TotalRenderedFrames; + uint laggedAfter = engine.LaggedRenderedFrames; uint totalDelta = totalAfter - totalBefore; uint laggedDelta = laggedAfter - laggedBefore; double steadyLagPercent = totalDelta == 0 ? 100 : laggedDelta * 100d / totalDelta; - string path = await _obs!.SaveReplayAsync(); + string path = await engine.SaveReplayAsync(); bool passed = File.Exists(path) && new FileInfo(path).Length > 0 && - _obs.IsGameHooked && - _obs.EncodedFrameCount > 0 && + engine.IsGameHooked && + engine.EncodedFrameCount > 0 && steadyLagPercent < 10; Log.Write( $"OBS_GAME_TEST {(passed ? "PASS" : "FAIL")}: " + - $"hooked={_obs.IsGameHooked}, frames={_obs.EncodedFrameCount}, " + + $"hooked={engine.IsGameHooked}, frames={engine.EncodedFrameCount}, " + $"steadyLag={laggedDelta}/{totalDelta} ({steadyLagPercent:0.0}%), " + $"path={path}"); Shutdown(passed ? 0 : 8); @@ -992,6 +1054,50 @@ private async void RunGameCaptureTest(string[] args) } } + private async void RunGameCaptureIdleTest() + { + try + { + string root = Path.Combine( + Path.GetTempPath(), + "Captail", + $"obs_game_idle_{Environment.ProcessId}"); + _config = new Config + { + ReplayEnabled = true, + BufferSeconds = 300, + FrameRate = 240, + RecordingResolution = "1080p", + BitrateMbps = 50, + Codec = "av1", + CaptureSource = "game", + CaptureSystemAudio = true, + CaptureMicrophone = false, + OutputDirectory = root, + }; + if (!TryStartPipeline(showError: false)) + throw new InvalidOperationException("OBS Game Capture did not start."); + + await Task.Delay(TimeSpan.FromSeconds(3)); + bool passed = !_obs!.IsGameHooked && + !_obs.IsActive && + !_obs.IsGameCaptureDetectorActive && + _obs.BufferedBytes == 0 && + _obs.EncodedFrameCount == 0; + Log.Write( + $"OBS_GAME_IDLE_TEST {(passed ? "PASS" : "FAIL")}: " + + $"hooked={_obs.IsGameHooked}, active={_obs.IsActive}, " + + $"detector={_obs.IsGameCaptureDetectorActive}, " + + $"frames={_obs.EncodedFrameCount}, bytes={_obs.BufferedBytes}"); + Shutdown(passed ? 0 : 18); + } + catch (Exception exception) + { + Log.Write($"OBS_GAME_IDLE_TEST FAIL: {exception}"); + Shutdown(19); + } + } + private async void RunFaultRecoveryTest() { try @@ -1473,7 +1579,7 @@ private void StartCaptureStateMonitor() private async Task RefreshAutomaticCaptureStateSafeAsync() { - if (_uiOnly || !IsReplayRunning || + if (_uiOnly || Volatile.Read(ref _exiting) != 0 || Interlocked.Exchange(ref _captureStateRefreshInProgress, 1) != 0) { @@ -1482,12 +1588,15 @@ private async Task RefreshAutomaticCaptureStateSafeAsync() try { + RefreshReplayOffGameWarning(); + if (!IsReplayRunning) + return; if (!await _pipelineGate.WaitAsync(0)) return; try { ObsReplayEngine? engine = _obs; - if (engine is null || !engine.IsAutomaticCapture) + if (engine is null) return; (bool changed, string description) = await RunOnObsThreadAsync(() => @@ -1516,6 +1625,62 @@ await RunOnObsThreadAsync(() => } } + private void RefreshReplayOffGameWarning() + { + Config? config = _config; + if (config is null || + !config.WarnWhenGameStartsWithReplayOff || + config.ReplayEnabled || + IsReplayRunning) + { + _pendingReplayOffGame = ""; + _pendingReplayOffGameSamples = 0; + if (IsReplayRunning) + _warnedReplayOffGames.Clear(); + return; + } + + Interop.CaptureInterop.ForegroundAppInfo foreground = + Interop.CaptureInterop.ForegroundApplication(); + if (!ObsReplayEngine.ShouldWakeGameCapture(foreground)) + { + _pendingReplayOffGame = ""; + _pendingReplayOffGameSamples = 0; + return; + } + + string gameKey = string.IsNullOrWhiteSpace(foreground.FullPath) + ? foreground.Executable + : foreground.FullPath; + if (_warnedReplayOffGames.Contains(gameKey)) + return; + + if (!string.Equals( + _pendingReplayOffGame, + gameKey, + StringComparison.OrdinalIgnoreCase)) + { + _pendingReplayOffGame = gameKey; + _pendingReplayOffGameSamples = 1; + return; + } + + _pendingReplayOffGameSamples++; + if (_pendingReplayOffGameSamples < 4) + return; + + _warnedReplayOffGames.Add(gameKey); + _pendingReplayOffGame = ""; + _pendingReplayOffGameSamples = 0; + string gameName = Path.GetFileNameWithoutExtension(foreground.Executable); + ShowOverlayNotification( + "!", + Localization.Text("L.Notify.ReplayOffGameTitle"), + Localization.Format("L.Notify.ReplayOffGameDetail", gameName), + OverlayTone.Warning, + 5200); + } + private async Task MonitorPipelineSafeAsync() { try @@ -1821,10 +1986,10 @@ private void OpenSettings() $"sha256:{new string('0', 64)}"); return Task.FromResult( new UpdateRelease( - new Version(0, 2, 0), - "v0.2.0", + new Version(0, 2, 2), + "v0.2.2", new Uri( - "https://github.com/FaulMit/captail/releases/tag/v0.2.0"), + "https://github.com/FaulMit/captail/releases/tag/v0.2.2"), true, asset, asset, @@ -2220,7 +2385,10 @@ private void UpdateUiState() : Localization.Text("L.Tray.Disabled"); } if (_saveMenuItem is not null) + { _saveMenuItem.InputGestureText = _config?.Hotkey ?? ""; + _saveMenuItem.IsEnabled = active && availableReplaySeconds > 0; + } if (_toggleMenuItem is not null) _toggleMenuItem.InputGestureText = _config?.ToggleReplayHotkey ?? ""; diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj index bb672e4..b3498b0 100644 --- a/src/Captail/Captail.csproj +++ b/src/Captail/Captail.csproj @@ -15,7 +15,7 @@ Captail Assets\Captail.ico app.manifest - 0.2.0 + 0.2.1 true $(DefineConstants);MICROSOFT_STORE diff --git a/src/Captail/ClipEditorWindow.xaml b/src/Captail/ClipEditorWindow.xaml index fa65780..a394491 100644 --- a/src/Captail/ClipEditorWindow.xaml +++ b/src/Captail/ClipEditorWindow.xaml @@ -67,6 +67,38 @@ + + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _timelineImages = []; private double _selectionStart; private double _selectionEnd; @@ -37,6 +46,8 @@ public partial class ClipEditorWindow : Window private bool _resumeAfterScrub; private bool _fullscreenProgressScrubbing; private bool _updatingFullscreenProgress; + private bool _previewProgressScrubbing; + private bool _updatingPreviewProgress; private bool _resumeAfterOverwriteConfirmation; private bool _saveInProgress; private Visibility _playerVisibilityBeforeOverwrite = Visibility.Collapsed; @@ -48,6 +59,9 @@ public partial class ClipEditorWindow : Window private NativePoint _lastCursorPosition; private DateTime _lastPointerActivityUtc; private VideoStreamInfo? _videoInfo; + private bool _previewMode; + private bool _editorAssetsStarted; + private int _playbackSpeedIndex = 3; public ObservableCollection AudioTracks { get; } = []; @@ -55,16 +69,19 @@ public ClipEditorWindow( ReplayLibrary library, string rootDirectory, ReplayClip clip, - Action onSaved) + Action onSaved, + ClipWindowMode mode = ClipWindowMode.Trim) { _library = library; _rootDirectory = rootDirectory; _clip = clip; _onSaved = onSaved; + _previewMode = mode == ClipWindowMode.Preview; _selectionEnd = Math.Max(MinimumSelectionSeconds, clip.Duration.TotalSeconds); InitializeComponent(); DataContext = this; ClipNameText.Text = clip.Name; + ApplyWindowModeLayout(adjustWindow: true); if (clip.ThumbnailPath is not null && File.Exists(clip.ThumbnailPath)) PreviewImage.Source = LoadBitmap(clip.ThumbnailPath, 900); UpdateRangeText(); @@ -74,12 +91,15 @@ public ClipEditorWindow( _playbackTimer.Tick += async (_, _) => await UpdatePlaybackAsync(); _fullscreenUiTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; _fullscreenUiTimer.Tick += (_, _) => UpdateFullscreenControls(); + _speedFeedbackTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.25) }; + _speedFeedbackTimer.Tick += (_, _) => HidePlaybackSpeedFeedback(); Loaded += async (_, _) => await LoadEditorAsync(); SourceInitialized += (_, _) => ApplyNativeCornerPreference(); Closed += (_, _) => { _playbackTimer.Stop(); _fullscreenUiTimer.Stop(); + _speedFeedbackTimer.Stop(); Topmost = _restoreTopmost; _lifetimeCts.Cancel(); PreviewPlayer.Shutdown(); @@ -89,13 +109,88 @@ public ClipEditorWindow( private async Task LoadEditorAsync() { - Task timelineTask = LoadTimelineThumbnailsAsync(); Task videoInfoTask = LoadVideoInfoAsync(); - await LoadAudioTracksAsync(); + Task timelineTask = _previewMode + ? Task.CompletedTask + : LoadTimelineThumbnailsAsync(); + _editorAssetsStarted = !_previewMode; + await LoadAudioTracksAsync(loadWaveforms: !_previewMode); await InitializePreviewAsync(); + if (_previewMode && PreviewPlayer.IsReady) + await StartPlaybackAsync(_selectionStart); await Task.WhenAll(timelineTask, videoInfoTask); } + private void ApplyWindowModeLayout(bool adjustWindow) + { + HeaderTrimIcon.Visibility = _previewMode + ? Visibility.Collapsed + : Visibility.Visible; + HeaderPreviewIcon.Visibility = _previewMode + ? Visibility.Visible + : Visibility.Collapsed; + HeaderTitleText.Text = Localization.Text( + _previewMode ? "L.Library.PreviewTitle" : "L.Library.TrimTitle"); + Title = HeaderTitleText.Text; + + TimelineEditorPanel.Visibility = _previewMode + ? Visibility.Collapsed + : Visibility.Visible; + EditorActionsPanel.Visibility = _previewMode + ? Visibility.Collapsed + : Visibility.Visible; + PreviewModePanel.Visibility = _previewMode + ? Visibility.Visible + : Visibility.Collapsed; + NormalPlaybackBar.Visibility = _previewMode + ? Visibility.Collapsed + : Visibility.Visible; + TimelineRow.Height = _previewMode + ? GridLength.Auto + : new GridLength(1, GridUnitType.Star); + ActionsRow.Height = _previewMode ? new GridLength(0) : GridLength.Auto; + + if (!adjustWindow) + return; + double targetHeight = _previewMode ? 720 : 790; + if (IsLoaded) + { + double delta = targetHeight - ActualHeight; + Rect workArea = SystemParameters.WorkArea; + Top = Math.Clamp(Top - delta / 2, workArea.Top, workArea.Bottom - targetHeight); + } + Height = targetHeight; + Dispatcher.BeginInvoke(DispatcherPriority.Render, () => + { + EditorWorkspace.UpdateLayout(); + PreviewPlayer.InvalidateVisual(); + }); + } + + private void EnterTrimMode_Click(object sender, RoutedEventArgs e) + { + if (!_previewMode) + return; + _previewMode = false; + ApplyWindowModeLayout(adjustWindow: true); + BeginEditorAssetsLoad(); + } + + private void BeginEditorAssetsLoad() + { + if (_editorAssetsStarted) + return; + _editorAssetsStarted = true; + _ = LoadEditorAssetsAsync(); + } + + private async Task LoadEditorAssetsAsync() + { + await Task.WhenAll( + LoadTimelineThumbnailsAsync(), + Task.WhenAll(AudioTracks.Select(LoadWaveformAsync))); + } + private async Task LoadVideoInfoAsync() { try @@ -259,7 +354,7 @@ private async Task LoadTimelineThumbnailsAsync() } } - private async Task LoadAudioTracksAsync() + private async Task LoadAudioTracksAsync(bool loadWaveforms = true) { try { @@ -279,8 +374,11 @@ private async Task LoadAudioTracksAsync() : Visibility.Collapsed; UpdateMergeAudioState(); - foreach (AudioTrackRow row in AudioTracks) - _ = LoadWaveformAsync(row); + if (loadWaveforms) + { + foreach (AudioTrackRow row in AudioTracks) + _ = LoadWaveformAsync(row); + } } catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) { @@ -356,6 +454,7 @@ await PreviewPlayer.LoadAsync( _playerLoading = false; PlayButton.IsEnabled = !_lifetimeCts.IsCancellationRequested && PreviewPlayer.IsReady; + PreviewPlayButton.IsEnabled = PlayButton.IsEnabled; UpdatePlayIcon(); UpdatePlaybackText(); UpdateTimelineVisual(); @@ -649,6 +748,64 @@ private void FullscreenProgress_MouseLeftButtonDown( ShowFullscreenControls(); } + private void PreviewProgress_DragStarted( + object sender, + DragStartedEventArgs e) + { + _previewProgressScrubbing = true; + _resumeAfterScrub = _playing; + PauseForTimelineEdit(); + } + + private void PreviewProgress_ValueChanged( + object sender, + RoutedPropertyChangedEventArgs e) + { + if (_updatingPreviewProgress || !PreviewPlayer.IsReady) + return; + if (!_previewProgressScrubbing) + { + _ = SeekAsync(e.NewValue); + return; + } + _playbackPosition = Math.Clamp(e.NewValue, _selectionStart, _selectionEnd); + PreviewPlayer.Seek(_playbackPosition, exact: false); + UpdatePlaybackText(readPlayerPosition: false); + UpdateTimelineVisual(); + } + + private void PreviewProgress_DragCompleted( + object sender, + DragCompletedEventArgs e) + { + if (!_previewProgressScrubbing) + return; + bool resume = _resumeAfterScrub; + _resumeAfterScrub = false; + _previewProgressScrubbing = false; + _playbackPosition = Math.Clamp( + PreviewProgressSlider.Value, + _selectionStart, + _selectionEnd); + PreviewPlayer.Seek(_playbackPosition, exact: true); + UpdatePlaybackText(readPlayerPosition: false); + UpdateTimelineVisual(); + if (resume) + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + UpdatePlayIcon(); + } + } + + private void PreviewProgress_MouseLeftButtonDown( + object sender, + MouseButtonEventArgs e) + { + Focus(); + } + private void AudioTrackToggle_Click(object sender, RoutedEventArgs e) { UpdateMergeAudioState(); @@ -765,6 +922,8 @@ private void UpdatePlaybackText(bool readPlayerPosition = true) FormatTime(_clip.Duration, false); if (FullscreenPlaybackTimeText is not null) FullscreenPlaybackTimeText.Text = PlaybackTimeText.Text; + if (PreviewPlaybackTimeText is not null) + PreviewPlaybackTimeText.Text = PlaybackTimeText.Text; if (FullscreenProgressSlider is not null && !_fullscreenProgressScrubbing) { _updatingFullscreenProgress = true; @@ -782,6 +941,23 @@ private void UpdatePlaybackText(bool readPlayerPosition = true) _updatingFullscreenProgress = false; } } + if (PreviewProgressSlider is not null && !_previewProgressScrubbing) + { + _updatingPreviewProgress = true; + try + { + PreviewProgressSlider.Minimum = _selectionStart; + PreviewProgressSlider.Maximum = _selectionEnd; + PreviewProgressSlider.Value = Math.Clamp( + position, + _selectionStart, + _selectionEnd); + } + finally + { + _updatingPreviewProgress = false; + } + } } private void UpdatePlayIcon() @@ -790,6 +966,8 @@ private void UpdatePlayIcon() return; Geometry icon = (Geometry)FindResource(_playing ? "IconPause" : "IconPlay"); PlayIcon.Data = icon; + if (PreviewPlayIcon is not null) + PreviewPlayIcon.Data = icon; if (FullscreenPlayIcon is not null) FullscreenPlayIcon.Data = icon; } @@ -996,6 +1174,8 @@ private void EnterFullscreen() if (_isFullscreen) return; + HidePlaybackSpeedFeedback(immediate: true); + _restoreBounds = new Rect(Left, Top, ActualWidth, ActualHeight); _restoreWindowState = WindowState; _restoreTopmost = Topmost; @@ -1037,6 +1217,8 @@ private void ExitFullscreen() if (!_isFullscreen) return; + HidePlaybackSpeedFeedback(immediate: true); + _isFullscreen = false; _fullscreenUiTimer.Stop(); FullscreenControlBar.BeginAnimation(OpacityProperty, null); @@ -1063,6 +1245,7 @@ private void ExitFullscreen() Width = _restoreBounds.Width; Height = _restoreBounds.Height; WindowState = _restoreWindowState; + ApplyWindowModeLayout(adjustWindow: false); EditorWorkspace.UpdateLayout(); Focus(); } @@ -1199,6 +1382,90 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e) Forward_Click(this, new RoutedEventArgs()); e.Handled = true; } + else if (e.Key == Key.Up) + { + ShowFullscreenControls(); + ChangePlaybackSpeed(1); + e.Handled = true; + } + else if (e.Key == Key.Down) + { + ShowFullscreenControls(); + ChangePlaybackSpeed(-1); + e.Handled = true; + } + } + + private void ChangePlaybackSpeed(int direction) + { + if (!PreviewPlayer.IsReady || direction == 0) + return; + + int nextIndex = Math.Clamp( + _playbackSpeedIndex + Math.Sign(direction), + 0, + PlaybackSpeeds.Length - 1); + if (nextIndex == _playbackSpeedIndex) + return; + + _playbackSpeedIndex = nextIndex; + double speed = PlaybackSpeeds[_playbackSpeedIndex]; + PreviewPlayer.SetPlaybackSpeed(speed); + ShowPlaybackSpeedFeedback(speed); + } + + private void ShowPlaybackSpeedFeedback(double speed) + { + string value = $"{speed:0.##}×"; + PreviewSpeedFeedbackText.Text = value; + FullscreenSpeedFeedbackText.Text = value; + + Border visible = _isFullscreen + ? FullscreenSpeedFeedback + : PreviewSpeedFeedback; + Border hidden = _isFullscreen + ? PreviewSpeedFeedback + : FullscreenSpeedFeedback; + + hidden.BeginAnimation(OpacityProperty, null); + hidden.Visibility = Visibility.Collapsed; + hidden.Opacity = 0; + visible.BeginAnimation(OpacityProperty, null); + visible.Visibility = Visibility.Visible; + visible.Opacity = 1; + + _speedFeedbackTimer.Stop(); + _speedFeedbackTimer.Start(); + } + + private void HidePlaybackSpeedFeedback(bool immediate = false) + { + _speedFeedbackTimer.Stop(); + Border[] feedbackBadges = [PreviewSpeedFeedback, FullscreenSpeedFeedback]; + foreach (Border badge in feedbackBadges) + { + badge.BeginAnimation(OpacityProperty, null); + if (badge.Visibility != Visibility.Visible) + continue; + if (immediate) + { + badge.Opacity = 0; + badge.Visibility = Visibility.Collapsed; + continue; + } + + var animation = new DoubleAnimation(0, TimeSpan.FromMilliseconds(170)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + animation.Completed += (_, _) => + { + badge.BeginAnimation(OpacityProperty, null); + badge.Opacity = 0; + badge.Visibility = Visibility.Collapsed; + }; + badge.BeginAnimation(OpacityProperty, animation); + } } private void Close_Click(object sender, RoutedEventArgs e) => Close(); diff --git a/src/Captail/Config.cs b/src/Captail/Config.cs index a75caae..603c5fa 100644 --- a/src/Captail/Config.cs +++ b/src/Captail/Config.cs @@ -21,6 +21,7 @@ public sealed class Config public string Hotkey { get; set; } = "Ctrl+Shift+F10"; public string ToggleReplayHotkey { get; set; } = "Ctrl+Shift+F9"; public bool ReplayEnabled { get; set; } = true; + public bool WarnWhenGameStartsWithReplayOff { get; set; } = true; public bool ShowRecordingIndicator { get; set; } = true; /// "top-left", "top-right", "bottom-left", or "bottom-right". public string RecordingIndicatorPosition { get; set; } = "top-right"; @@ -159,6 +160,7 @@ public void CopyFrom(Config source) Hotkey = source.Hotkey; ToggleReplayHotkey = source.ToggleReplayHotkey; ReplayEnabled = source.ReplayEnabled; + WarnWhenGameStartsWithReplayOff = source.WarnWhenGameStartsWithReplayOff; ShowRecordingIndicator = source.ShowRecordingIndicator; RecordingIndicatorPosition = source.RecordingIndicatorPosition; Codec = source.Codec; diff --git a/src/Captail/Languages/Strings.de.xaml b/src/Captail/Languages/Strings.de.xaml index 2ff3fe4..5d84539 100644 --- a/src/Captail/Languages/Strings.de.xaml +++ b/src/Captail/Languages/Strings.de.xaml @@ -9,6 +9,7 @@ Minimieren Schließen Öffnen Sie Captail auf GitHub + Info Fehler melden GitHub-Formular mit vorausgefüllten Systemdaten öffnen Idee @@ -65,6 +66,9 @@ HOTKEYS Instant Replay + Beim Spielstart warnen + Hinweis, wenn Instant Replay aus ist + Zeigt einmal eine dezente Benachrichtigung, wenn ein Spiel erkannt wird und Instant Replay deaktiviert ist. Die Aufnahme wird nicht automatisch aktiviert. Aufnahmeanzeige Kleines Statussymbol in der ausgewählten Bildschirmecke Anzeigeposition @@ -157,6 +161,8 @@ Wiederholungen konnten nicht geladen werden. Wiederholungen aktualisieren Im Ordner anzeigen + Replay abspielen + Replay-Wiedergabe Trimmen Löschen Diese Wiederholung löschen? @@ -191,6 +197,14 @@ Ausgewählt · {0} 5 Sekunden zurück 5 Sekunden vorwärts + Wiedergabe / Pause + Wiedergabe umschalten + Springen + 5 Sekunden zurück oder vor + Wiedergabegeschwindigkeit + Abwärts langsamer · aufwärts schneller + Playeransicht umschalten + Vollbild verlassen oder Player schließen Überschreiben Original-Replay überschreiben? Der ausgewählte Bereich und die aktivierten Audiospuren ersetzen die Originaldatei. Dies kann nicht rückgängig gemacht werden. @@ -201,6 +215,9 @@ Aktivieren/deaktivieren Schaltet Instant Replay ein oder aus Klicken Sie auf „Fertig“, um die Änderungen zu übernehmen. + Nicht gespeicherte Änderungen + Abbrechen oder Fertig verwenden. + Abbrechen Tasten drücken… Fügen Sie Strg / Alt / Umschalt hinzu Hotkey bereits verwendet @@ -251,6 +268,8 @@ Die Aufnahme wurde nicht gestartet Einstellungen angewendet Instant Replay aus + Instant Replay ist aus + {0} läuft. Replays werden nicht aufgezeichnet. Aktivieren Sie Instant Replay vor dem Speichern. Replay wird gespeichert… Die letzten {0} werden auf die Festplatte geschrieben. diff --git a/src/Captail/Languages/Strings.en.xaml b/src/Captail/Languages/Strings.en.xaml index 260b334..c45f75d 100644 --- a/src/Captail/Languages/Strings.en.xaml +++ b/src/Captail/Languages/Strings.en.xaml @@ -12,6 +12,7 @@ Minimize Close Open Captail on GitHub + About Report bug Open the GitHub form with system details prefilled Feature @@ -68,6 +69,9 @@ HOTKEYS Instant Replay + Warn when a game starts + Notifies you if replay is off + Shows one quiet notification when a game is detected while Instant Replay is disabled. It does not turn recording on automatically. Recording indicator Small status icon in the selected screen corner Indicator position @@ -160,6 +164,8 @@ Could not load replays. Refresh replays Show in folder + Play replay + Replay player Trim Delete Delete this replay? @@ -194,6 +200,14 @@ Selected · {0} Back 5 seconds Forward 5 seconds + Play / pause + Toggle playback + Seek + Back or forward 5 seconds + Playback speed + Down slower · Up faster + Toggle player view + Exit full screen or close player Overwrite Overwrite original replay? Selected range and enabled audio tracks will replace original file. This cannot be undone. @@ -204,6 +218,9 @@ Enable / disable Toggles Instant Replay Press “Done” to apply changes. + Unsaved changes + Use Cancel or Done. + Cancel Press keys… Add Ctrl / Alt / Shift Hotkey already in use @@ -254,6 +271,8 @@ Capture did not start Settings applied Instant Replay off + Instant Replay is off + {0} is running. Replays are not being recorded. Enable Instant Replay before saving. Saving replay… Writing the last {0} to disk. diff --git a/src/Captail/Languages/Strings.es.xaml b/src/Captail/Languages/Strings.es.xaml index e525625..a25a38a 100644 --- a/src/Captail/Languages/Strings.es.xaml +++ b/src/Captail/Languages/Strings.es.xaml @@ -9,6 +9,7 @@ minimizar Cerrar Abrir Captail en GitHub + Acerca de Informar de un error Abrir el formulario de GitHub con los datos del sistema rellenados Idea @@ -65,6 +66,9 @@ TECLAS RÁPIDAS Repetición instantánea + Avisar al iniciar un juego + Te avisa si la repetición está desactivada + Muestra una sola notificación discreta al detectar un juego con la repetición instantánea desactivada. No activa la grabación automáticamente. Indicador de grabación Pequeño icono de estado en la esquina de la pantalla seleccionada Posición del indicador @@ -157,6 +161,8 @@ No se pudieron cargar las repeticiones. Actualizar repeticiones Mostrar en carpeta + Reproducir repetición + Reproductor de repeticiones Recortar Eliminar ¿Eliminar esta repetición? @@ -191,6 +197,14 @@ Seleccionado · {0} Atrás 5 segundos Adelante 5 segundos + Reproducir / pausar + Alternar reproducción + Buscar + Atrás o adelante 5 segundos + Velocidad de reproducción + Abajo más lento · arriba más rápido + Cambiar vista del reproductor + Salir de pantalla completa o cerrar Sobrescribir ¿Sobrescribir la repetición original? El rango seleccionado y las pistas de audio habilitadas reemplazarán el archivo original. Esto no se puede deshacer. @@ -201,6 +215,9 @@ Activar/desactivar Activa o desactiva la repetición instantánea Presione "Listo" para aplicar los cambios. + Cambios sin guardar + Usa Cancelar o Hecho. + Cancelar Presione las teclas… Agregar Ctrl/Alt/Mayús Tecla de acceso rápido ya en uso @@ -251,6 +268,8 @@ La captura no comenzó Configuración aplicada Repetición instantánea desactivada + La repetición instantánea está desactivada + {0} está en ejecución. No se están grabando repeticiones. Activa la repetición instantánea antes de guardar. Guardando repetición… Escribiendo el último {0} en el disco. diff --git a/src/Captail/Languages/Strings.fr.xaml b/src/Captail/Languages/Strings.fr.xaml index d5f573f..0099815 100644 --- a/src/Captail/Languages/Strings.fr.xaml +++ b/src/Captail/Languages/Strings.fr.xaml @@ -9,6 +9,7 @@ Réduire Fermer Ouvrir Captail sur GitHub + À propos Signaler un bug Ouvrir le formulaire GitHub avec les données système préremplies Idée @@ -65,6 +66,9 @@ TOUCHES DE RACCOURCI Replay instantané + Avertir au lancement d’un jeu + Alerte si le replay est désactivé + Affiche une seule notification discrète lorsqu’un jeu est détecté et que le replay instantané est désactivé. L’enregistrement ne démarre pas automatiquement. Indicateur d'enregistrement Petite icône d'état dans le coin de l'écran sélectionné Position de l'indicateur @@ -157,6 +161,8 @@ Impossible de charger les rediffusions. Actualiser les rediffusions Afficher dans le dossier + Lire le replay + Lecteur de replay Couper Supprimer Supprimer cette rediffusion ? @@ -191,6 +197,14 @@ Sélectionné · {0} Retour 5 secondes Avancer de 5 secondes + Lecture / pause + Basculer la lecture + Navigation + Reculer ou avancer de 5 secondes + Vitesse de lecture + Bas plus lent · haut plus rapide + Changer la vue du lecteur + Quitter le plein écran ou fermer Écraser Écraser la rediffusion originale ? La plage sélectionnée et les pistes audio activées remplaceront le fichier d'origine. Cela ne peut pas être annulé. @@ -201,6 +215,9 @@ Activer / désactiver Active ou désactive le replay instantané Appuyez sur « Terminé » pour appliquer les modifications. + Modifications non enregistrées + Utilisez Annuler ou Terminé. + Annuler Appuyez sur les touches… Ajouter Ctrl/Alt/Maj Raccourci clavier déjà utilisé @@ -251,6 +268,8 @@ La capture n'a pas démarré Paramètres appliqués Replay instantané désactivé + Le replay instantané est désactivé + {0} est en cours d’exécution. Les replays ne sont pas enregistrés. Activez le replay instantané avant d'enregistrer. Sauvegarde de la rediffusion… Écriture du dernier {0} sur le disque. diff --git a/src/Captail/Languages/Strings.ja.xaml b/src/Captail/Languages/Strings.ja.xaml index 7ea8883..327ad6c 100644 --- a/src/Captail/Languages/Strings.ja.xaml +++ b/src/Captail/Languages/Strings.ja.xaml @@ -9,6 +9,7 @@ 最小化する 閉じる GitHub で Captail を開く + 情報 バグを報告 システム情報を入力済みの GitHub フォームを開く 機能提案 @@ -65,6 +66,9 @@ ホットキー インスタントリプレイ + ゲーム開始時に通知 + インスタントリプレイがオフの場合に通知 + インスタントリプレイが無効な状態でゲームを検出すると、通知を一度だけ表示します。録画は自動で有効になりません。 録画インジケーター 選択した画面隅にある小さなステータス アイコン インジケーターの位置 @@ -157,6 +161,8 @@ リプレイを読み込めませんでした。 リプレイを更新する フォルダ内に表示 + リプレイを再生 + リプレイプレイヤー トリム 削除 このリプレイを削除しますか? @@ -191,6 +197,14 @@ 選択済み · {0} 5秒戻る 5秒進む + 再生 / 一時停止 + 再生を切り替えます + シーク + 5秒戻る / 進む + 再生速度 + 下で遅く · 上で速く + プレーヤー表示を切り替えます + 全画面を終了または閉じます 上書き 元のリプレイを上書きしますか? 選択した範囲と有効なオーディオ トラックは、元のファイルを置き換えます。これを元に戻すことはできません。 @@ -201,6 +215,9 @@ 有効/無効 インスタント リプレイを切り替えます 「完了」を押して変更を適用します。 + 未保存の変更 + キャンセルまたは完了を使用します。 + キャンセル キーを押してください… Ctrl / Alt / Shift を追加 ホットキーはすでに使用されています @@ -251,6 +268,8 @@ キャプチャが開始されませんでした 適用された設定 インスタント リプレイ オフ + インスタントリプレイはオフです + {0} が起動中です。リプレイは録画されていません。 保存する前にインスタント リプレイを有効にしてください。 リプレイを保存中… 最後の {0} をディスクに書き込みます。 diff --git a/src/Captail/Languages/Strings.ko.xaml b/src/Captail/Languages/Strings.ko.xaml index d7a0957..febc503 100644 --- a/src/Captail/Languages/Strings.ko.xaml +++ b/src/Captail/Languages/Strings.ko.xaml @@ -9,6 +9,7 @@ 최소화 닫기 GitHub에서 Captail 열기 + 정보 버그 신고 시스템 정보가 미리 입력된 GitHub 양식 열기 기능 제안 @@ -65,6 +66,9 @@ 단축키 인스턴트 리플레이 + 게임 시작 시 알림 + 인스턴트 리플레이가 꺼져 있으면 알림 + 인스턴트 리플레이가 꺼진 상태에서 게임을 감지하면 알림을 한 번 표시합니다. 녹화는 자동으로 켜지지 않습니다. 녹화 표시 선택한 화면 모서리에 있는 작은 상태 아이콘 표시기 위치 @@ -157,6 +161,8 @@ 리플레이를 로드할 수 없습니다. 리플레이 새로고침 폴더에 표시 + 리플레이 재생 + 리플레이 플레이어 트림 삭제 이 리플레이를 삭제하시겠습니까? @@ -191,6 +197,14 @@ 선택됨 · {0} 5초 뒤로 5초 앞으로 + 재생 / 일시정지 + 재생 전환 + 탐색 + 5초 뒤로 / 앞으로 + 재생 속도 + 아래로 느리게 · 위로 빠르게 + 플레이어 보기 전환 + 전체 화면 종료 또는 닫기 덮어쓰기 원본 리플레이를 덮어쓰시겠습니까? 선택한 범위와 활성화된 오디오 트랙이 원본 파일을 대체합니다. 이 작업은 취소할 수 없습니다. @@ -201,6 +215,9 @@ 활성화 / 비활성화 인스턴트 리플레이를 켜거나 끕니다. 변경 사항을 적용하려면 "완료"를 누르세요. + 저장되지 않은 변경 사항 + 취소 또는 완료를 사용하세요. + 취소 키를 누르세요… Ctrl/Alt/Shift 추가 이미 사용 중인 단축키 @@ -251,6 +268,8 @@ 캡처가 시작되지 않았습니다. 설정이 적용되었습니다. 인스턴트 리플레이 꺼짐 + 인스턴트 리플레이가 꺼져 있습니다 + {0} 실행 중입니다. 리플레이가 녹화되지 않습니다. 저장하기 전에 인스턴트 리플레이를 활성화하세요. 리플레이 저장 중… 마지막 {0}를 디스크에 씁니다. diff --git a/src/Captail/Languages/Strings.pl.xaml b/src/Captail/Languages/Strings.pl.xaml index e42eb38..5932e32 100644 --- a/src/Captail/Languages/Strings.pl.xaml +++ b/src/Captail/Languages/Strings.pl.xaml @@ -9,6 +9,7 @@ Minimalizuj Zamknij Otwórz Captail w GitHubie + Informacje Zgłoś błąd Otwórz formularz GitHub z uzupełnionymi danymi systemu Pomysł @@ -65,6 +66,9 @@ Skróty klawiszowe Natychmiastowa powtórka + Ostrzegaj po uruchomieniu gry + Powiadamia, gdy powtórka jest wyłączona + Wyświetla jedno dyskretne powiadomienie po wykryciu gry, gdy natychmiastowa powtórka jest wyłączona. Nie włącza nagrywania automatycznie. Wskaźnik nagrywania Mała ikona stanu w rogu wybranego ekranu Pozycja wskaźnika @@ -157,6 +161,8 @@ Nie udało się wczytać powtórek. Odśwież powtórki Pokaż w folderze + Odtwórz powtórkę + Odtwarzacz powtórek Przytnij Usuń Usunąć tę powtórkę? @@ -191,6 +197,14 @@ Wybrane · {0} Cofnij się o 5 sekund Do przodu o 5 sekund + Odtwórz / pauza + Przełącz odtwarzanie + Przewijanie + Wstecz lub do przodu o 5 sekund + Szybkość odtwarzania + W dół wolniej · w górę szybciej + Przełącz widok odtwarzacza + Wyjdź z pełnego ekranu lub zamknij Zastąp Zastąpić oryginalną powtórkę? Wybrany zakres i włączone ścieżki audio zastąpią oryginalny plik. Tego nie można cofnąć. @@ -201,6 +215,9 @@ Włącz / wyłącz Włącza natychmiastową powtórkę Naciśnij „Gotowe”, aby zastosować zmiany. + Niezapisane zmiany + Użyj Anuluj lub Gotowe. + Anuluj Naciśnij klawisze… Dodaj Ctrl / Alt / Shift Klawisz skrótu jest już używany @@ -251,6 +268,8 @@ Przechwytywanie nie rozpoczęło się Zastosowano ustawienia Natychmiastowa powtórka wyłączona + Natychmiastowa powtórka jest wyłączona + Uruchomiono {0}. Powtórki nie są nagrywane. Włącz natychmiastową powtórkę przed zapisaniem. Zapisuję powtórkę… Zapisywanie ostatniego {0} na dysku. diff --git a/src/Captail/Languages/Strings.pt.xaml b/src/Captail/Languages/Strings.pt.xaml index b777ede..7c5df35 100644 --- a/src/Captail/Languages/Strings.pt.xaml +++ b/src/Captail/Languages/Strings.pt.xaml @@ -9,6 +9,7 @@ Minimizar Fechar Abra o Captail no GitHub + Sobre Relatar erro Abrir o formulário do GitHub com os dados do sistema preenchidos Ideia @@ -65,6 +66,9 @@ TECLAS DE ATALHO Replay instantâneo + Avisar ao iniciar um jogo + Notifica se o replay estiver desativado + Exibe uma única notificação discreta ao detectar um jogo com o replay instantâneo desativado. Não ativa a gravação automaticamente. Indicador de gravação Pequeno ícone de status no canto da tela selecionada Posição do indicador @@ -157,6 +161,8 @@ Não foi possível carregar os replays. Atualizar replays Mostrar na pasta + Reproduzir replay + Reprodutor de replay Aparar Excluir Excluir este replay? @@ -191,6 +197,14 @@ Selecionado · {0} Voltar 5 segundos Avançar 5 segundos + Reproduzir / pausar + Alternar reprodução + Navegar + Voltar ou avançar 5 segundos + Velocidade de reprodução + Baixo mais lento · cima mais rápido + Alternar visualização do player + Sair da tela cheia ou fechar Substituir Substituir o replay original? O intervalo selecionado e as faixas de áudio ativadas substituirão o arquivo original. Isto não pode ser desfeito. @@ -201,6 +215,9 @@ Ativar/desativar Ativa ou desativa o replay instantâneo Pressione “Concluído” para aplicar as alterações. + Alterações não salvas + Use Cancelar ou Concluído. + Cancelar Pressione as teclas… Adicionar Ctrl/Alt/Shift Tecla de atalho já em uso @@ -251,6 +268,8 @@ A captura não começou Configurações aplicadas Replay instantâneo desativado + O replay instantâneo está desativado + {0} está em execução. Os replays não estão sendo gravados. Ative o Instant Replay antes de salvar. Salvando replay… Gravando o último {0} no disco. diff --git a/src/Captail/Languages/Strings.ru.xaml b/src/Captail/Languages/Strings.ru.xaml index 4b0fe19..bbcdc4f 100644 --- a/src/Captail/Languages/Strings.ru.xaml +++ b/src/Captail/Languages/Strings.ru.xaml @@ -12,6 +12,7 @@ Свернуть Закрыть Открыть Captail на GitHub + О приложении Сообщить об ошибке Открыть форму GitHub с заполненной информацией о системе Идея @@ -68,6 +69,9 @@ БИНДЫ Мгновенный повтор + Предупреждать при запуске игры + Напомнит, если повтор выключен + Показывает одно ненавязчивое уведомление, когда игра запущена, а мгновенный повтор выключен. Запись автоматически не включается. Индикатор записи Маленький значок в выбранном углу экрана Положение индикатора @@ -160,6 +164,8 @@ Не удалось загрузить повторы. Обновить список Показать в папке + Воспроизвести повтор + Просмотр повтора Обрезать Удалить Удалить этот повтор? @@ -194,6 +200,14 @@ Выбрано · {0} Назад на 5 секунд Вперёд на 5 секунд + Пуск / пауза + Управление воспроизведением + Перемотка + Назад или вперёд на 5 секунд + Скорость воспроизведения + Вниз медленнее · вверх быстрее + Переключить вид плеера + Выйти из полного экрана или закрыть Перезаписать Перезаписать исходный повтор? Выбранный участок и включённые аудиодорожки заменят исходный файл. Отменить это действие нельзя. @@ -204,6 +218,9 @@ Включить / выключить Переключает мгновенный повтор Нажмите «Готово», чтобы применить изменения. + Есть несохранённые изменения + Используйте «Отмена» или «Готово». + Отмена Нажмите клавиши… Добавьте Ctrl / Alt / Shift Бинд уже используется @@ -254,6 +271,8 @@ Захват не запущен Настройки применены Повтор выключен + Мгновенный повтор выключен + Запущена {0}. Повторы не записываются. Включите мгновенный повтор перед сохранением. Сохраняю повтор… Записываю последние {0} на диск. diff --git a/src/Captail/Languages/Strings.uk.xaml b/src/Captail/Languages/Strings.uk.xaml index 5426534..df4dfa2 100644 --- a/src/Captail/Languages/Strings.uk.xaml +++ b/src/Captail/Languages/Strings.uk.xaml @@ -9,6 +9,7 @@ Згорнути Закрити Відкрийте Captail на GitHub + Про застосунок Повідомити про помилку Відкрити форму GitHub із заповненими даними про систему Ідея @@ -65,6 +66,9 @@ ГАРЯЧІ КЛАВІШІ Миттєвий повтор + Попереджати під час запуску гри + Нагадає, якщо повтор вимкнено + Показує одне ненав’язливе сповіщення, коли гру запущено, а миттєвий повтор вимкнено. Запис автоматично не вмикається. Індикатор запису Маленький значок стану у вибраному куті екрана Положення індикатора @@ -157,6 +161,8 @@ Не вдалося завантажити повтори. Оновити повтори Показати в папці + Відтворити повтор + Перегляд повтору Обрізати Видалити Видалити цей повтор? @@ -191,6 +197,14 @@ Вибрано · {0} Назад 5 секунд Уперед на 5 секунд + Відтворення / пауза + Керування відтворенням + Перемотування + Назад або вперед на 5 секунд + Швидкість відтворення + Вниз повільніше · вгору швидше + Перемкнути вигляд плеєра + Вийти з повного екрана або закрити Перезаписати Перезаписати оригінальний повтор? Вибраний діапазон і ввімкнені звукові доріжки замінять оригінальний файл. Це неможливо скасувати. @@ -201,6 +215,9 @@ Увімкнути/вимкнути Перемикає миттєвий повтор Натисніть «Готово», щоб застосувати зміни. + Є незбережені зміни + Використайте «Скасувати» або «Готово». + Скасувати Натисніть клавіші… Додайте Ctrl / Alt / Shift Гаряча клавіша вже використовується @@ -251,6 +268,8 @@ Захоплення не почалося Налаштування застосовано Миттєвий повтор вимкнено + Миттєвий повтор вимкнено + {0} запущено. Повтори не записуються. Увімкніть миттєвий повтор перед збереженням. Збереження повтору… Запис останніх {0} на диск. diff --git a/src/Captail/Languages/Strings.zh.xaml b/src/Captail/Languages/Strings.zh.xaml index c40aa8f..798a941 100644 --- a/src/Captail/Languages/Strings.zh.xaml +++ b/src/Captail/Languages/Strings.zh.xaml @@ -12,6 +12,7 @@ 最小化 关闭 在 GitHub 上打开 Captail + 关于 报告问题 打开已预填系统信息的 GitHub 表单 建议 @@ -68,6 +69,9 @@ 快捷键 即时回放 + 游戏启动时提醒 + 即时回放关闭时通知 + 检测到游戏启动且即时回放已关闭时显示一次安静通知。不会自动开启录制。 录制指示器 在所选屏幕角落显示的小状态图标 指示器位置 @@ -160,6 +164,8 @@ 无法加载回放。 刷新回放 在文件夹中显示 + 播放回放 + 回放播放器 剪辑 删除 删除这段回放? @@ -194,6 +200,14 @@ 已选 · {0} 后退 5 秒 快进 5 秒 + 播放 / 暂停 + 切换播放状态 + 跳转 + 后退或前进 5 秒 + 播放速度 + 向下减速 · 向上加速 + 切换播放器视图 + 退出全屏或关闭播放器 覆盖 覆盖原始回放? 所选范围和启用的音轨将替换原文件。此操作无法撤销。 @@ -204,6 +218,9 @@ 启用 / 禁用 切换即时回放 按“完成”以应用更改。 + 更改尚未保存 + 使用“取消”或“完成”。 + 取消 按下按键… 添加 Ctrl / Alt / Shift 快捷键已被占用 @@ -254,6 +271,8 @@ 捕获未能启动 设置已应用 即时回放已关闭 + 即时回放已关闭 + {0} 正在运行,回放不会被录制。 保存前请先启用即时回放。 正在保存回放… 正在将最近 {0} 写入磁盘。 diff --git a/src/Captail/MpvHost.cs b/src/Captail/MpvHost.cs index 910306a..968b940 100644 --- a/src/Captail/MpvHost.cs +++ b/src/Captail/MpvHost.cs @@ -230,6 +230,16 @@ public void Pause() SetProperty("pause", "yes"); } + public void SetPlaybackSpeed(double speed) + { + if (!IsReady) + return; + double normalized = Math.Clamp(speed, 0.25, 2.0); + SetProperty( + "speed", + normalized.ToString("0.##", CultureInfo.InvariantCulture)); + } + public void Seek(double positionSeconds, bool exact) { if (!IsReady) diff --git a/src/Captail/ObsNative.cs b/src/Captail/ObsNative.cs index b275ec5..bfcce48 100644 --- a/src/Captail/ObsNative.cs +++ b/src/Captail/ObsNative.cs @@ -269,6 +269,20 @@ internal static extern nint obs_output_create( [return: MarshalAs(UnmanagedType.I1)] internal static extern bool obs_output_active(nint output); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + internal static extern bool obs_output_can_pause(nint output); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + internal static extern bool obs_output_pause( + nint output, + [MarshalAs(UnmanagedType.I1)] bool pause); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + internal static extern bool obs_output_paused(nint output); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern int obs_output_get_total_frames(nint output); diff --git a/src/Captail/ObsReplayEngine.cs b/src/Captail/ObsReplayEngine.cs index 99ef6b3..e410c3c 100644 --- a/src/Captail/ObsReplayEngine.cs +++ b/src/Captail/ObsReplayEngine.cs @@ -14,6 +14,11 @@ public sealed class ObsReplayEngine : IDisposable private const string RequiredObsVersion = "32.1.2"; private const int AutomaticHookStableChecks = 2; private const int AutomaticFallbackStableChecks = 3; + // Game Capture still needs occasional video ticks to discover a newly launched game. + // Two ticks per second are the minimum verified rate for a reliable hook handshake. + private const int GameCaptureIdleFrameRate = 2; + private const int GameCaptureIdleReleaseSeconds = 10; + private const int GameCaptureDetectorStartupSeconds = 8; private const int Windows11InitialBuild = 22000; private const long MonitorCaptureMethodAuto = 0; private const long MonitorCaptureMethodWgc = 2; @@ -24,8 +29,9 @@ public sealed class ObsReplayEngine : IDisposable "discordcanary", "discordptb", "dwm", "eadesktop", "epicgameslauncher", "explorer", "firefox", "galaxyclient", "lockapp", "livelywpf", "mediaplayer", "mpv", "ms-teams", "msedge", "nvcontainer", "obs32", - "obs64", "opera", "opera_gx", "searchapp", "searchhost", - "shellexperiencehost", "signal", "slack", "spotify", "startmenuexperiencehost", + "obs64", "opera", "opera_gx", "screenclippinghost", "searchapp", + "searchhost", "shellexperiencehost", "signal", "slack", "snippingtool", + "spotify", "startmenuexperiencehost", "steam", "steamwebhelper", "systemsettings", "telegram", "textinputhost", "ubisoftconnect", "vivaldi", "vlc", "wallpaper32", "wallpaper64", "wallpaper_engine", "webviewhost", "whatsapp", "wmplayer", "zoom", @@ -74,9 +80,15 @@ public sealed class ObsReplayEngine : IDisposable private uint _outputHeight; private uint _baseWidth; private uint _baseHeight; + private int _videoFrameRate; private bool _automaticGameActive; private bool _automaticDesktopFallbackActive; private bool _automaticGameSourceShowing; + private bool _gameCaptureDetectorShowing; + private DateTime _gameCaptureDetectorActivatedUtc; + private bool _idleOutputTransition; + private bool _gameOutputPaused; + private DateTime _gameHookLostUtc; private string _activeGameExecutable = ""; private string _pendingAutomaticGameExecutable = ""; private int _automaticHookStableChecks; @@ -93,6 +105,7 @@ public sealed class ObsReplayEngine : IDisposable Localization.Text("L.Engine.CapabilitiesPending")); public bool IsGameCapture { get; } public bool IsAutomaticCapture { get; } + internal bool IsGameCaptureDetectorActive => _gameCaptureDetectorShowing; public bool IsActive => _started && _output != 0 && @@ -130,7 +143,21 @@ public string Description } } - public bool IsGameHooked => + public bool IsGameHooked + { + get + { + if (!IsRawGameHooked) + return false; + string executable = ReadStringProcedure( + ObsNative.obs_source_get_proc_handler(_gameVideoSource), + "get_hooked", + "executable"); + return IsAutomaticCaptureCandidate(executable); + } + } + + private bool IsRawGameHooked => _gameVideoSource != 0 && ReadBoolProcedure( ObsNative.obs_source_get_proc_handler(_gameVideoSource), @@ -180,6 +207,18 @@ internal static bool ShouldUseAutomaticDesktopFallback( normalizedPath.Contains(marker, StringComparison.OrdinalIgnoreCase)); } + internal static bool ShouldWakeGameCapture( + CaptureInterop.ForegroundAppInfo foreground) + { + if (!IsAutomaticCaptureCandidate(foreground.Executable)) + return false; + + string normalizedPath = foreground.FullPath.Replace('/', '\\'); + return foreground.IsFullscreen || + AutomaticGamePathMarkers.Any(marker => + normalizedPath.Contains(marker, StringComparison.OrdinalIgnoreCase)); + } + internal static string? ResolveReplayGameExecutable( bool isAutomaticCapture, bool automaticGameIdentified, @@ -204,9 +243,14 @@ public bool IsHealthy { get { + if (_started && IsGameCapture && !IsGameHooked) + return true; if (!IsActive) return false; + if (_gameOutputPaused) + return true; + uint frames = ObsNative.obs_get_total_frames(); DateTime now = DateTime.UtcNow; if (_previousFrameCheckUtc == default || @@ -253,7 +297,17 @@ public bool RefreshCaptureState() if (_gameVideoSource == 0) return false; - bool hooked = IsGameHooked; + if (IsGameCapture && !_gameCaptureDetectorShowing) + { + if (!ShouldWakeGameCapture(CaptureInterop.ForegroundApplication())) + return false; + + SetGameCaptureDetectorShowing(true); + Log.Write("Game candidate found; Game Capture detector activated."); + return true; + } + + bool hooked = IsRawGameHooked; string executable = hooked ? ReadStringProcedure( ObsNative.obs_source_get_proc_handler(_gameVideoSource), @@ -263,8 +317,57 @@ public bool RefreshCaptureState() if (IsGameCapture) { - _activeGameExecutable = hooked ? executable : ""; - return false; + bool validHook = hooked && IsAutomaticCaptureCandidate(executable); + string previousExecutable = _activeGameExecutable; + bool outputWasActive = IsActive; + bool pausedBefore = _gameOutputPaused; + + if (validHook) + { + _activeGameExecutable = executable; + _lastRejectedAutomaticExecutable = ""; + _gameHookLostUtc = default; + ResumeOrStartGameReplayBuffer(); + } + else + { + _activeGameExecutable = ""; + if (hooked) + { + if (!string.Equals( + _lastRejectedAutomaticExecutable, + executable, + StringComparison.OrdinalIgnoreCase)) + { + _lastRejectedAutomaticExecutable = executable; + Log.Write( + "Game Capture ignored non-game process: " + + Path.GetFileName(executable)); + } + SetGameCaptureDetectorShowing(false); + } + else + { + _lastRejectedAutomaticExecutable = ""; + } + + SuspendOrReleaseGameReplayBuffer(); + + if (!hooked && !IsActive && + DateTime.UtcNow - _gameCaptureDetectorActivatedUtc >= + TimeSpan.FromSeconds(GameCaptureDetectorStartupSeconds) && + !ShouldWakeGameCapture(CaptureInterop.ForegroundApplication())) + { + SetGameCaptureDetectorShowing(false); + } + } + + return outputWasActive != IsActive || + pausedBefore != _gameOutputPaused || + !string.Equals( + previousExecutable, + _activeGameExecutable, + StringComparison.OrdinalIgnoreCase); } if (_desktopVideoSource == 0) @@ -509,6 +612,15 @@ public ReplaySaveOperation BeginSaveReplay( { if (IsAutomaticCapture) RefreshCaptureState(); + if (IsGameCapture && _gameOutputPaused) + { + if (!ObsNative.obs_output_pause(_output, false)) + throw new InvalidOperationException( + Localization.Text("L.Engine.BufferStartFailed")); + _gameOutputPaused = false; + _gameHookLostUtc = DateTime.UtcNow; + Log.Write("Paused Game Capture buffer resumed for replay save."); + } Task completion; ulong initialMuxBytes; @@ -699,6 +811,32 @@ private void InitializeObs() _outputWidth = outputWidth; _outputHeight = outputHeight; + ResetObsVideo( + IsGameCapture ? GameCaptureIdleFrameRate : _config.FrameRate, + diagnoseOnFailure: true); + + var audio = new ObsNative.AudioInfo + { + SamplesPerSecond = 48_000, + Speakers = ObsNative.SpeakerLayout.Stereo, + }; + if (!ObsNative.obs_reset_audio(ref audio)) + throw new InvalidOperationException( + Localization.Text("L.Engine.AudioFailed")); + + ObsNative.obs_add_module_path( + ToObsPath(Path.Combine(baseDirectory, "obs-plugins", "64bit")), + ToObsPath(Path.Combine(pluginDataRoot, "obs-plugins", "%module%"))); + ObsNative.obs_load_all_modules(); + ObsNative.obs_post_load_modules(); + } + + private void ResetObsVideo(int frameRate, bool diagnoseOnFailure = false) + { + if (_videoFrameRate == frameRate) + return; + + string baseDirectory = AppContext.BaseDirectory; nint graphicsModule = Marshal.StringToCoTaskMemUTF8( Path.Combine(baseDirectory, "libobs-d3d11.dll")); try @@ -706,12 +844,12 @@ private void InitializeObs() var video = new ObsNative.VideoInfo { GraphicsModule = graphicsModule, - FpsNum = (uint)_config.FrameRate, + FpsNum = (uint)frameRate, FpsDen = 1, BaseWidth = _baseWidth, BaseHeight = _baseHeight, - OutputWidth = outputWidth, - OutputHeight = outputHeight, + OutputWidth = _outputWidth, + OutputHeight = _outputHeight, OutputFormat = ObsNative.VideoFormat.Nv12, Adapter = 0, GpuConversion = true, @@ -724,30 +862,28 @@ private void InitializeObs() int result = ObsNative.obs_reset_video(ref video); if (result != 0) { - DiagnoseEffects(baseDirectory, dataRoot); + if (diagnoseOnFailure) + { + DiagnoseEffects( + baseDirectory, + Path.Combine(baseDirectory, "data")); + } throw new InvalidOperationException( Localization.Format("L.Engine.VideoFailed", result)); } + _videoFrameRate = frameRate; + if (_videoEncoder != 0) + { + ObsNative.obs_encoder_set_video( + _videoEncoder, + ObsNative.obs_get_video()); + } + Log.Write($"OBS video clock: {frameRate} FPS."); } finally { Marshal.FreeCoTaskMem(graphicsModule); } - - var audio = new ObsNative.AudioInfo - { - SamplesPerSecond = 48_000, - Speakers = ObsNative.SpeakerLayout.Stereo, - }; - if (!ObsNative.obs_reset_audio(ref audio)) - throw new InvalidOperationException( - Localization.Text("L.Engine.AudioFailed")); - - ObsNative.obs_add_module_path( - ToObsPath(Path.Combine(baseDirectory, "obs-plugins", "64bit")), - ToObsPath(Path.Combine(pluginDataRoot, "obs-plugins", "%module%"))); - ObsNative.obs_load_all_modules(); - ObsNative.obs_post_load_modules(); } private EncoderCapabilities DetectCapabilities() @@ -903,30 +1039,24 @@ private void CreateSources() { _gameVideoSource = CreateGameSource(desktopFallback: false); _videoSource = _gameVideoSource; + // Detector stays dormant until a plausible foreground game appears. + // This prevents hooks into unrelated fullscreen applications and + // leaves GPU nearly idle while no game is running. } if (_videoSource == 0) throw new InvalidOperationException( Localization.Text("L.Engine.VideoSourceFailed")); - const uint systemMix = 1u; - if (IsGameCapture && _config.CaptureSystemAudio) - { - ObsNative.obs_source_set_audio_mixers(_gameVideoSource, systemMix); - ObsNative.obs_source_set_volume( - _gameVideoSource, - NormalizeVolume(_config.SystemAudioVolume)); - } - // Captail always has one video source. Connecting it directly avoids an // extra scene-composition pass, which matters at 144/240 FPS. ObsNative.obs_set_output_source(0, _videoSource); - if (!IsGameCapture && _config.CaptureSystemAudio) + if (_config.CaptureSystemAudio) { nint system = CreateAudioSource( "wasapi_output_capture", - "Captail System Audio", + IsGameCapture ? "Captail Game Audio" : "Captail System Audio", _config.SystemAudioDeviceId, _config.SeparateAudioTracks ? 1u : 1u, NormalizeVolume(_config.SystemAudioVolume)); @@ -970,7 +1100,7 @@ private nint CreateGameSource(bool desktopFallback) ObsNative.obs_data_set_bool( settings, "capture_audio", - !desktopFallback && _config.CaptureSystemAudio); + false); nint source = ObsNative.obs_source_create( "game_capture", desktopFallback @@ -1308,6 +1438,143 @@ private enum EncoderLoadProfile Extreme, } + private void ResumeOrStartGameReplayBuffer() + { + if (_output == 0) + throw new InvalidOperationException( + Localization.Text("L.Engine.BufferUnavailable")); + + if (ObsNative.obs_output_active(_output)) + { + if (_gameOutputPaused) + { + if (!ObsNative.obs_output_pause(_output, false)) + throw new InvalidOperationException( + Localization.Text("L.Engine.BufferStartFailed")); + _gameOutputPaused = false; + _previousFrameCheckUtc = default; + Log.Write("Game Capture found; Replay Buffer resumed."); + } + return; + } + + ResetObsVideo(_config.FrameRate); + if (!ObsNative.obs_output_start(_output)) + { + string error = PtrToString(ObsNative.obs_output_get_last_error(_output)); + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(error) + ? Localization.Text("L.Engine.BufferStartFailed") + : error); + } + + _gameOutputPaused = false; + _replayWindowStartedUtc = DateTime.UtcNow; + _previousFrameCheckUtc = default; + Log.Write("Game Capture found; Replay Buffer started."); + } + + private void SuspendOrReleaseGameReplayBuffer() + { + if (_output == 0 || !ObsNative.obs_output_active(_output)) + { + _gameHookLostUtc = default; + _gameOutputPaused = false; + return; + } + + DateTime now = DateTime.UtcNow; + if (_gameHookLostUtc == default) + _gameHookLostUtc = now; + + if (!_gameOutputPaused) + { + if (ObsNative.obs_output_can_pause(_output) && + ObsNative.obs_output_pause(_output, true)) + { + _gameOutputPaused = true; + Log.Write( + "Game Capture lost; Replay Buffer paused for recovery grace."); + } + else + { + Log.Write( + "Game Capture lost; Replay Buffer pause unavailable."); + } + } + + if (now - _gameHookLostUtc < + TimeSpan.FromSeconds(GameCaptureIdleReleaseSeconds)) + { + return; + } + + StopGameReplayBufferForIdle(); + } + + private void StopGameReplayBufferForIdle() + { + if (_output == 0 || !ObsNative.obs_output_active(_output)) + return; + + _idleOutputTransition = true; + try + { + ObsNative.obs_output_stop(_output); + for (int attempt = 0; + attempt < 20 && ObsNative.obs_output_active(_output); + attempt++) + { + Thread.Sleep(25); + } + if (ObsNative.obs_output_active(_output)) + { + ObsNative.obs_output_force_stop(_output); + for (int attempt = 0; + attempt < 20 && ObsNative.obs_output_active(_output); + attempt++) + { + Thread.Sleep(25); + } + } + } + finally + { + _idleOutputTransition = false; + _gameOutputPaused = false; + _gameHookLostUtc = default; + _replayWindowStartedUtc = default; + } + + if (ObsNative.obs_output_active(_output)) + throw new InvalidOperationException( + Localization.Text("L.Engine.BufferUnexpectedStop")); + + ResetObsVideo(GameCaptureIdleFrameRate); + if (!ShouldWakeGameCapture(CaptureInterop.ForegroundApplication())) + SetGameCaptureDetectorShowing(false); + Log.Write("Game Capture idle; Replay Buffer released."); + } + + private void SetGameCaptureDetectorShowing(bool showing) + { + if (_gameVideoSource == 0 || _gameCaptureDetectorShowing == showing) + return; + + if (showing) + { + ObsNative.obs_source_inc_showing(_gameVideoSource); + _gameCaptureDetectorActivatedUtc = DateTime.UtcNow; + } + else + { + ObsNative.obs_source_dec_showing(_gameVideoSource); + _gameCaptureDetectorActivatedUtc = default; + } + + _gameCaptureDetectorShowing = showing; + } + private void CreateReplayBuffer() { // Game target is discovered at runtime. Save to root first, then move @@ -1380,6 +1647,14 @@ private void CreateReplayBuffer() _stoppedCallback, 0); + if (IsGameCapture) + { + _replayWindowStartedUtc = default; + Log.Write( + "Game Capture idle; hook detector active, Replay Buffer sleeping."); + return; + } + if (!ObsNative.obs_output_start(_output)) { string error = PtrToString(ObsNative.obs_output_get_last_error(_output)); @@ -1421,7 +1696,7 @@ private void OnReplaySaved(nint _, nint __) private void OnOutputStopped(nint _, nint __) { - if (_disposing || _resettingReplayWindow) + if (_disposing || _resettingReplayWindow || _idleOutputTransition) return; string error = PtrToString(ObsNative.obs_output_get_last_error(_output)); Faulted?.Invoke( @@ -1577,6 +1852,11 @@ public void Dispose() ObsNative.obs_source_dec_showing(_gameVideoSource); _automaticGameSourceShowing = false; } + if (_gameCaptureDetectorShowing && _gameVideoSource != 0) + { + ObsNative.obs_source_dec_showing(_gameVideoSource); + _gameCaptureDetectorShowing = false; + } if (_desktopVideoSource != 0) ObsNative.obs_source_remove(_desktopVideoSource); diff --git a/src/Captail/ReplayStatusIndicatorWindow.xaml b/src/Captail/ReplayStatusIndicatorWindow.xaml index 09c8afd..ee92c7c 100644 --- a/src/Captail/ReplayStatusIndicatorWindow.xaml +++ b/src/Captail/ReplayStatusIndicatorWindow.xaml @@ -13,12 +13,8 @@ - - - - - + diff --git a/src/Captail/SettingsWindow.xaml b/src/Captail/SettingsWindow.xaml index 77eb48a..3c3e623 100644 --- a/src/Captail/SettingsWindow.xaml +++ b/src/Captail/SettingsWindow.xaml @@ -11,15 +11,17 @@ Background="Transparent" Foreground="{StaticResource TextPrimaryBrush}" FontFamily="{StaticResource FontUi}" + Closing="Window_Closing" PreviewKeyDown="Window_PreviewKeyDown"> - - - + CornerRadius="{StaticResource RadiusWindow}" + RenderTransformOrigin="0.5,0.5"> + + + @@ -57,6 +59,15 @@ Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}" StrokeThickness="1.8" Width="15" Height="15" Stretch="Uniform"/> + + + + + + + @@ -998,9 +1072,6 @@ - @@ -1008,52 +1079,88 @@ - - - - + + - - + +