diff --git a/BetterGenshinImpact/BetterGenshinImpact.csproj b/BetterGenshinImpact/BetterGenshinImpact.csproj index 0cee1885..60fbdb73 100644 --- a/BetterGenshinImpact/BetterGenshinImpact.csproj +++ b/BetterGenshinImpact/BetterGenshinImpact.csproj @@ -48,6 +48,7 @@ + @@ -65,6 +66,7 @@ + diff --git a/BetterGenshinImpact/Core/Monitor/DirectInputMonitor.cs b/BetterGenshinImpact/Core/Monitor/DirectInputMonitor.cs index d77956fd..f617f06d 100644 --- a/BetterGenshinImpact/Core/Monitor/DirectInputMonitor.cs +++ b/BetterGenshinImpact/Core/Monitor/DirectInputMonitor.cs @@ -1,6 +1,7 @@ using BetterGenshinImpact.Core.Recorder; using SharpDX.DirectInput; using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; diff --git a/BetterGenshinImpact/Core/Recorder/GlobalKeyMouseRecord.cs b/BetterGenshinImpact/Core/Recorder/GlobalKeyMouseRecord.cs index 8703b1d5..4b20a6e8 100644 --- a/BetterGenshinImpact/Core/Recorder/GlobalKeyMouseRecord.cs +++ b/BetterGenshinImpact/Core/Recorder/GlobalKeyMouseRecord.cs @@ -9,8 +9,13 @@ using SharpDX.DirectInput; using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Threading.Tasks; using System.Windows.Forms; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Video; +using NAudio.Wave; +using SharpAvi; using Wpf.Ui.Violeta.Controls; namespace BetterGenshinImpact.Core.Recorder; @@ -20,6 +25,8 @@ public class GlobalKeyMouseRecord : Singleton private readonly ILogger _logger = App.GetLogger(); private KeyMouseRecorder? _recorder; + + private SharpAviRecorder _sharpAviRecorder; private readonly Dictionary _keyDownState = []; @@ -58,18 +65,29 @@ public class GlobalKeyMouseRecord : Singleton _logger.LogInformation("录制:{Text}", "实时任务已暂停"); _logger.LogInformation("注意:录制时遇到主界面(鼠标永远在界面中心)和其他界面(鼠标可自由移动,比如地图等)的切换,请把手离开鼠标等待录制模式切换日志"); + // 先实例化 + _recorder = new KeyMouseRecorder(); + _directInputMonitor = new DirectInputMonitor(); + var videoPath = Global.Absolute(@"video"); + if (!Directory.Exists(videoPath)) + { + Directory.CreateDirectory(videoPath); + } + _sharpAviRecorder = new SharpAviRecorder( Path.Combine(videoPath, $"{DateTime.Now:yyyyMMddHH_mmssffff.avi}"), + CodecIds.MotionJpeg, 90, 0, SupportedWaveFormat.WAVE_FORMAT_44M16, false, 0); + + TaskTriggerDispatcher.Instance().StopTimer(); + for (var i = 3; i >= 1; i--) { _logger.LogInformation("{Sec}秒后启动录制...", i); await Task.Delay(1000); } - TaskTriggerDispatcher.Instance().StopTimer(); - _timer.Start(); - _recorder = new KeyMouseRecorder(); - _directInputMonitor = new DirectInputMonitor(); + // _timer.Start(); + _sharpAviRecorder.Start(); _directInputMonitor.Start(); Status = KeyMouseRecorderStatus.Recording; @@ -89,8 +107,10 @@ public class GlobalKeyMouseRecord : Singleton _directInputMonitor?.Stop(); _directInputMonitor?.Dispose(); _directInputMonitor = null; + + _sharpAviRecorder.Dispose(); - _timer.Stop(); + // _timer.Stop(); _logger.LogInformation("录制:{Text}", "结束录制"); @@ -187,11 +207,11 @@ public class GlobalKeyMouseRecord : Singleton public void GlobalHookMouseMoveBy(MouseState state) { + // Debug.WriteLine($"MouseMoveBy: {state.X}, {state.Y}"); if (state is { X: 0, Y: 0 } || !_isInMainUi) { return; } - // Debug.WriteLine($"MouseMoveBy: {state.X}, {state.Y}"); _recorder?.MouseMoveBy(state); } } diff --git a/BetterGenshinImpact/Core/Recorder/KeyMouseRecorder.cs b/BetterGenshinImpact/Core/Recorder/KeyMouseRecorder.cs index e468f7ee..19c5977d 100644 --- a/BetterGenshinImpact/Core/Recorder/KeyMouseRecorder.cs +++ b/BetterGenshinImpact/Core/Recorder/KeyMouseRecorder.cs @@ -15,6 +15,8 @@ namespace BetterGenshinImpact.Core.Recorder; public class KeyMouseRecorder { public List MacroEvents { get; } = []; + + public List MouseMoveByMacroEvents { get; } = []; public DateTime StartTime { get; set; } = DateTime.UtcNow; diff --git a/BetterGenshinImpact/Core/Video/SharpAviRecorder.cs b/BetterGenshinImpact/Core/Video/SharpAviRecorder.cs new file mode 100644 index 00000000..0dc96d65 --- /dev/null +++ b/BetterGenshinImpact/Core/Video/SharpAviRecorder.cs @@ -0,0 +1,258 @@ +using NAudio.Wave; +using SharpAvi.Codecs; +using SharpAvi.Output; +using System; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; +using SharpAvi; +using FourCC = SharpAvi.FourCC; +using WaveFormat = NAudio.Wave.WaveFormat; + +namespace BetterGenshinImpact.Core.Video; + +public class SharpAviRecorder : IDisposable +{ + // public static readonly FourCC MJPEG_IMAGE_SHARP = "IMG#"; + + private readonly int screenWidth; + private readonly int screenHeight; + private readonly AviWriter writer; + private readonly IAviVideoStream videoStream; + private readonly IAviAudioStream audioStream; + private readonly WaveInEvent audioSource; + private readonly Thread screenThread; + private readonly ManualResetEvent stopThread = new ManualResetEvent(false); + private readonly AutoResetEvent videoFrameWritten = new AutoResetEvent(false); + private readonly AutoResetEvent audioBlockWritten = new AutoResetEvent(false); + + public SharpAviRecorder(string fileName, + FourCC codec, int quality, + int audioSourceIndex, SupportedWaveFormat audioWaveFormat, bool encodeAudio, int audioBitRate) + { + System.Windows.Media.Matrix toDevice; + using (var source = new HwndSource(new HwndSourceParameters())) + { + toDevice = source.CompositionTarget.TransformToDevice; + } + + screenWidth = (int)Math.Round(SystemParameters.PrimaryScreenWidth * toDevice.M11); + screenHeight = (int)Math.Round(SystemParameters.PrimaryScreenHeight * toDevice.M22); + + // Create AVI writer and specify FPS + writer = new AviWriter(fileName) + { + FramesPerSecond = 60, + EmitIndex1 = true, + }; + + // Create video stream + videoStream = CreateVideoStream(codec, quality); + // Set only name. Other properties were when creating stream, + // either explicitly by arguments or implicitly by the encoder used + videoStream.Name = "Screencast"; + + if (audioSourceIndex >= 0) + { + var waveFormat = ToWaveFormat(audioWaveFormat); + + audioStream = CreateAudioStream(waveFormat, encodeAudio, audioBitRate); + // Set only name. Other properties were when creating stream, + // either explicitly by arguments or implicitly by the encoder used + audioStream.Name = "Voice"; + + audioSource = new WaveInEvent + { + DeviceNumber = audioSourceIndex, + WaveFormat = waveFormat, + // Buffer size to store duration of 1 frame + BufferMilliseconds = (int)Math.Ceiling(1000 / writer.FramesPerSecond), + NumberOfBuffers = 3, + }; + audioSource.DataAvailable += audioSource_DataAvailable; + } + + screenThread = new Thread(RecordScreen) + { + Name = typeof(SharpAviRecorder).Name + ".RecordScreen", + IsBackground = true + }; + } + + public void Start() + { + if (audioSource != null) + { + videoFrameWritten.Set(); + audioBlockWritten.Reset(); + audioSource.StartRecording(); + } + + screenThread.Start(); + } + + private IAviVideoStream CreateVideoStream(FourCC codec, int quality) + { + // Select encoder type based on FOURCC of codec + if (codec == CodecIds.Uncompressed) + { + return writer.AddUncompressedVideoStream(screenWidth, screenHeight); + } + else if (codec == CodecIds.MotionJpeg) + { + // Use M-JPEG based on WPF (Windows only) + return writer.AddMJpegWpfVideoStream(screenWidth, screenHeight, quality); + } + // else if (codec == MJPEG_IMAGE_SHARP) + // { + // // Use M-JPEG based on the SixLabors.ImageSharp package (cross-platform) + // // Included in the SharpAvi.ImageSharp package + // return writer.AddMJpegImageSharpVideoStream(screenWidth, screenHeight, quality); + // } + else + { + return writer.AddMpeg4VcmVideoStream(screenWidth, screenHeight, (double)writer.FramesPerSecond, + // It seems that all tested MPEG-4 VfW codecs ignore the quality affecting parameters passed through VfW API + // They only respect the settings from their own configuration dialogs, and Mpeg4VideoEncoder currently has no support for this + quality: quality, + codec: codec, + // Most of VfW codecs expect single-threaded use, so we wrap this encoder to special wrapper + // Thus all calls to the encoder (including its instantiation) will be invoked on a single thread although encoding (and writing) is performed asynchronously + forceSingleThreadedAccess: true); + } + } + + private IAviAudioStream CreateAudioStream(WaveFormat waveFormat, bool encode, int bitRate) + { + // Create encoding or simple stream based on settings + if (encode) + { + // LAME DLL path is set in App.OnStartup() + return writer.AddMp3LameAudioStream(waveFormat.Channels, waveFormat.SampleRate, bitRate); + } + else + { + return writer.AddAudioStream( + channelCount: waveFormat.Channels, + samplesPerSecond: waveFormat.SampleRate, + bitsPerSample: waveFormat.BitsPerSample); + } + } + + private static WaveFormat ToWaveFormat(SupportedWaveFormat waveFormat) + { + switch (waveFormat) + { + case SupportedWaveFormat.WAVE_FORMAT_44M16: + return new WaveFormat(44100, 16, 1); + case SupportedWaveFormat.WAVE_FORMAT_44S16: + return new WaveFormat(44100, 16, 2); + default: + throw new NotSupportedException("Wave formats other than '16-bit 44.1kHz' are not currently supported."); + } + } + + public void Dispose() + { + stopThread.Set(); + screenThread.Join(); + if (audioSource != null) + { + audioSource.StopRecording(); + audioSource.DataAvailable -= audioSource_DataAvailable; + } + + // Close writer: the remaining data is written to a file and file is closed + writer.Close(); + + stopThread.Close(); + } + + private void RecordScreen() + { + var stopwatch = new Stopwatch(); + var buffer = new byte[screenWidth * screenHeight * 4]; + Task videoWriteTask = null; + var isFirstFrame = true; + var shotsTaken = 0; + var timeTillNextFrame = TimeSpan.Zero; + stopwatch.Start(); + + while (!stopThread.WaitOne(timeTillNextFrame)) + { + GetScreenshot(buffer); + shotsTaken++; + + // Wait for the previous frame is written + if (!isFirstFrame) + { + videoWriteTask.Wait(); + videoFrameWritten.Set(); + } + + if (audioStream != null) + { + var signalled = WaitHandle.WaitAny(new WaitHandle[] { audioBlockWritten, stopThread }); + if (signalled == 1) + break; + } + + // Start asynchronous (encoding and) writing of the new frame + // Overloads with Memory parameters are available on .NET 5+ +#if NET5_0_OR_GREATER + videoWriteTask = videoStream.WriteFrameAsync(true, buffer.AsMemory(0, buffer.Length)); +#else + videoWriteTask = videoStream.WriteFrameAsync(true, buffer, 0, buffer.Length); +#endif + + timeTillNextFrame = TimeSpan.FromSeconds(shotsTaken / (double)writer.FramesPerSecond - stopwatch.Elapsed.TotalSeconds); + if (timeTillNextFrame < TimeSpan.Zero) + timeTillNextFrame = TimeSpan.Zero; + + isFirstFrame = false; + } + + stopwatch.Stop(); + + // Wait for the last frame is written + if (!isFirstFrame) + { + videoWriteTask.Wait(); + } + } + + private void GetScreenshot(byte[] buffer) + { + using (var bitmap = new Bitmap(screenWidth, screenHeight)) + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen(0, 0, 0, 0, new System.Drawing.Size(screenWidth, screenHeight)); + var bits = bitmap.LockBits(new Rectangle(0, 0, screenWidth, screenHeight), ImageLockMode.ReadOnly, PixelFormat.Format32bppRgb); + Marshal.Copy(bits.Scan0, buffer, 0, buffer.Length); + bitmap.UnlockBits(bits); + + // Should also capture the mouse cursor here, but skipping for simplicity + // For those who are interested, look at http://www.codeproject.com/Articles/12850/Capturing-the-Desktop-Screen-with-the-Mouse-Cursor + } + } + + private void audioSource_DataAvailable(object sender, WaveInEventArgs e) + { + var signalled = WaitHandle.WaitAny(new WaitHandle[] { videoFrameWritten, stopThread }); + if (signalled == 0 && e.BytesRecorded > 0) + { + // Overloads with Span parameters are available on .NET 5+ +#if NET5_0_OR_GREATER + audioStream.WriteBlock(e.Buffer.AsSpan(0, e.BytesRecorded)); +#else + audioStream.WriteBlock(e.Buffer, 0, e.BytesRecorded); +#endif + audioBlockWritten.Set(); + } + } +} \ No newline at end of file