Sfoglia il codice sorgente

Initial commit: ScreenRecorder project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nh99sysrj6-blip 6 mesi fa
commit
c3e4d8a1bc
6 ha cambiato i file con 631 aggiunte e 0 eliminazioni
  1. 11 0
      .gitignore
  2. 43 0
      AudioService.cs
  3. 155 0
      CaptureService.cs
  4. 373 0
      Program.cs
  5. 25 0
      README.md
  6. 24 0
      ScreenRecorder.csproj

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+# .NET build output
+bin/
+obj/
+
+# External tools
+ffmpeg.exe
+
+# Visual Studio
+.vs/
+*.user
+*.suo

+ 43 - 0
AudioService.cs

@@ -0,0 +1,43 @@
+using NAudio.Wave;
+
+namespace ScreenRecorder;
+
+/// <summary>
+/// 录制系统麦克风音频,保存为 WAV 文件
+/// </summary>
+public class AudioService : IDisposable
+{
+    private WaveInEvent? _waveIn;
+    private WaveFileWriter? _writer;
+    private string? _outputPath;
+
+    public void Start(string outputPath)
+    {
+        _outputPath = outputPath;
+        _waveIn = new WaveInEvent
+        {
+            WaveFormat = new WaveFormat(44100, 1),
+            BufferMilliseconds = 50
+        };
+
+        _writer = new WaveFileWriter(outputPath, _waveIn.WaveFormat);
+        _waveIn.DataAvailable += OnDataAvailable;
+        _waveIn.StartRecording();
+    }
+
+    private void OnDataAvailable(object? sender, WaveInEventArgs e)
+    {
+        _writer?.Write(e.Buffer, 0, e.BytesRecorded);
+    }
+
+    public void Stop()
+    {
+        _waveIn?.StopRecording();
+        _waveIn?.Dispose();
+        _waveIn = null;
+        _writer?.Dispose();
+        _writer = null;
+    }
+
+    public void Dispose() => Stop();
+}

+ 155 - 0
CaptureService.cs

@@ -0,0 +1,155 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Runtime.InteropServices;
+using SharpAvi;
+using SharpAvi.Output;
+using SharpAvi.Codecs;
+
+namespace ScreenRecorder;
+
+public class CaptureService : IDisposable
+{
+    private AviWriter? _writer;
+    private IAviVideoStream? _videoStream;
+    private Thread? _captureThread;
+    private volatile bool _isRecording;
+    private readonly int _fps;
+    private readonly int _quality;
+    private readonly bool _hideCursor;
+    private readonly Rectangle _captureArea;
+    private string? _outputPath;
+
+    public CaptureService(int fps = 30, int quality = 100, bool hideCursor = false)
+    {
+        _fps = fps;
+        _quality = quality;
+        _hideCursor = hideCursor;
+        // 录制主屏幕
+        var screen = System.Windows.Forms.Screen.PrimaryScreen!;
+        _captureArea = screen.Bounds;
+    }
+
+    public void Start(string outputPath)
+    {
+        _outputPath = outputPath;
+        _isRecording = true;
+
+        _writer = new AviWriter(outputPath)
+        {
+            FramesPerSecond = _fps,
+            EmitIndex1 = true
+        };
+
+        if (_quality == 101) // 无损模式:无压缩 RGB
+        {
+            var rawStream = _writer.AddVideoStream(
+                _captureArea.Width,
+                _captureArea.Height,
+                BitsPerPixel.Bpp32
+            );
+            rawStream.Codec = KnownFourCCs.Codecs.Uncompressed;
+            _videoStream = rawStream;
+        }
+        else // MJPEG 有损模式
+        {
+            _videoStream = _writer.AddMotionJpegVideoStream(
+                _captureArea.Width,
+                _captureArea.Height,
+                quality: _quality
+            );
+        }
+
+        _captureThread = new Thread(CaptureLoop) { IsBackground = true };
+        _captureThread.Start();
+    }
+
+    public void Stop()
+    {
+        _isRecording = false;
+        _captureThread?.Join(3000);
+        _writer?.Close();
+        _writer = null;
+    }
+
+    private void CaptureLoop()
+    {
+        var interval = TimeSpan.FromMilliseconds(1000.0 / _fps);
+        int w = _captureArea.Width, h = _captureArea.Height;
+        bool lossless = _quality == 101;
+
+        using var bitmap = new Bitmap(w, h, PixelFormat.Format32bppRgb);
+        using var graphics = Graphics.FromImage(bitmap);
+        int stride = w * 4;
+        var frameData = new byte[stride * h];
+
+        while (_isRecording)
+        {
+            var start = DateTime.UtcNow;
+
+            graphics.CopyFromScreen(_captureArea.Location, Point.Empty, _captureArea.Size);
+            if (!_hideCursor) DrawCursor(graphics);
+
+            var bits = bitmap.LockBits(
+                new Rectangle(0, 0, w, h),
+                ImageLockMode.ReadOnly,
+                PixelFormat.Format32bppRgb
+            );
+
+            if (lossless)
+            {
+                // 无压缩 AVI 需要 BGR 从下到上(行翻转)
+                for (int row = 0; row < h; row++)
+                {
+                    IntPtr src = bits.Scan0 + row * bits.Stride;
+                    int dstOffset = (h - 1 - row) * stride;
+                    Marshal.Copy(src, frameData, dstOffset, stride);
+                }
+            }
+            else
+            {
+                Marshal.Copy(bits.Scan0, frameData, 0, frameData.Length);
+            }
+
+            bitmap.UnlockBits(bits);
+            _videoStream!.WriteFrame(true, frameData, 0, frameData.Length);
+
+            var elapsed = DateTime.UtcNow - start;
+            var remaining = interval - elapsed;
+            if (remaining > TimeSpan.Zero)
+                Thread.Sleep(remaining);
+        }
+    }
+
+    public void Dispose() => Stop();
+
+    // Win32 API:获取鼠标位置和光标图标
+    [StructLayout(LayoutKind.Sequential)]
+    private struct CURSORINFO
+    {
+        public int cbSize;
+        public int flags;
+        public IntPtr hCursor;
+        public POINT ptScreenPos;
+    }
+
+    [StructLayout(LayoutKind.Sequential)]
+    private struct POINT { public int x, y; }
+
+    [DllImport("user32.dll")] static extern bool GetCursorInfo(ref CURSORINFO ci);
+    [DllImport("user32.dll")] static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int w, int h, int step, IntPtr brush, int flags);
+
+    private const int CURSOR_SHOWING = 1;
+    private const int DI_NORMAL = 3;
+
+    private void DrawCursor(Graphics g)
+    {
+        var ci = new CURSORINFO { cbSize = Marshal.SizeOf<CURSORINFO>() };
+        if (!GetCursorInfo(ref ci) || ci.flags != CURSOR_SHOWING) return;
+
+        int x = ci.ptScreenPos.x - _captureArea.X;
+        int y = ci.ptScreenPos.y - _captureArea.Y;
+        var hdc = g.GetHdc();
+        DrawIconEx(hdc, x, y, ci.hCursor, 0, 0, 0, IntPtr.Zero, DI_NORMAL);
+        g.ReleaseHdc(hdc);
+    }
+}

+ 373 - 0
Program.cs

@@ -0,0 +1,373 @@
+using System.Windows.Forms;
+using ScreenRecorder;
+
+var app = new RecorderForm();
+Application.Run(app);
+
+public class RecorderForm : Form
+{
+    private readonly Button _btnStart;
+    private readonly Button _btnStop;
+    private readonly Label _lblStatus;
+    private readonly Label _lblTimer;
+    private readonly Label _lblFps;
+    private readonly Label _lblQuality;
+    private readonly TrackBar _trackFps;
+    private readonly ComboBox _cmbQuality;
+    private readonly Label _lblFpsVal;
+    private readonly CheckBox _chkHideCursor;
+    private readonly System.Windows.Forms.Timer _timer;
+    private readonly ProgressBar _progressBar;
+    private readonly Label _lblProgress;
+
+    private CaptureService? _capture;
+    private DateTime _startTime;
+    private string _outputDir = Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
+    private string? _lastVideoPath;
+
+    private static string? FindFfmpeg()
+    {
+        var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
+        if (File.Exists(local)) return local;
+        foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';'))
+        {
+            var p = Path.Combine(dir.Trim(), "ffmpeg.exe");
+            if (File.Exists(p)) return p;
+        }
+        return null;
+    }
+
+    // Value: 0=无损H265, 1-100=MJPEG quality, 10/18=H264有损
+    private static readonly (string Label, int Value, string FfmpegArgs)[] QualityOptions =
+    [
+        ("无损 (H.265 CRF 0, yuv444p)",   0,   "-c:v libx265 -preset fast -crf 0 -pix_fmt yuv444p -tag:v hvc1"),
+        ("极高画质 (H.264 CRF 10)",        10,  "-c:v libx264 -preset fast -crf 10 -pix_fmt yuv420p"),
+        ("高画质 (H.264 CRF 18)",          18,  "-c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p"),
+        ("中画质 MJPEG 100",              100,  ""),
+        ("低画质 MJPEG 80",                80,  ""),
+        ("极低画质 MJPEG 50",              50,  ""),
+    ];
+
+    public RecorderForm()
+    {
+        Text = "屏幕录制";
+        Size = new System.Drawing.Size(360, 340);
+        FormBorderStyle = FormBorderStyle.FixedSingle;
+        MaximizeBox = false;
+        StartPosition = FormStartPosition.CenterScreen;
+
+        _lblStatus = new Label
+        {
+            Text = "就绪",
+            Location = new System.Drawing.Point(20, 12),
+            Size = new System.Drawing.Size(320, 22),
+            Font = new System.Drawing.Font("微软雅黑", 10)
+        };
+        _lblTimer = new Label
+        {
+            Text = "00:00:00",
+            Location = new System.Drawing.Point(20, 36),
+            Size = new System.Drawing.Size(320, 26),
+            Font = new System.Drawing.Font("Consolas", 15),
+            ForeColor = System.Drawing.Color.DarkBlue
+        };
+
+        _lblFps = new Label
+        {
+            Text = "帧率 (fps):",
+            Location = new System.Drawing.Point(20, 74),
+            Size = new System.Drawing.Size(80, 20),
+            Font = new System.Drawing.Font("微软雅黑", 9)
+        };
+        _trackFps = new TrackBar
+        {
+            Minimum = 1, Maximum = 60, Value = 30,
+            TickFrequency = 5, SmallChange = 1, LargeChange = 5,
+            Location = new System.Drawing.Point(100, 67),
+            Size = new System.Drawing.Size(190, 40)
+        };
+        _lblFpsVal = new Label
+        {
+            Text = "30",
+            Location = new System.Drawing.Point(295, 74),
+            Size = new System.Drawing.Size(40, 20),
+            Font = new System.Drawing.Font("Consolas", 10),
+            ForeColor = System.Drawing.Color.DarkBlue
+        };
+        _trackFps.ValueChanged += (_, _) => _lblFpsVal.Text = _trackFps.Value.ToString();
+
+        _lblQuality = new Label
+        {
+            Text = "画质:",
+            Location = new System.Drawing.Point(20, 120),
+            Size = new System.Drawing.Size(80, 20),
+            Font = new System.Drawing.Font("微软雅黑", 9)
+        };
+        _cmbQuality = new ComboBox
+        {
+            Location = new System.Drawing.Point(100, 117),
+            Size = new System.Drawing.Size(230, 26),
+            DropDownStyle = ComboBoxStyle.DropDownList,
+            Font = new System.Drawing.Font("微软雅黑", 9)
+        };
+        foreach (var (label, _, _) in QualityOptions)
+            _cmbQuality.Items.Add(label);
+        _cmbQuality.SelectedIndex = 0;
+
+        _chkHideCursor = new CheckBox
+        {
+            Text = "隐藏鼠标光标",
+            Location = new System.Drawing.Point(20, 155),
+            Size = new System.Drawing.Size(150, 22),
+            Font = new System.Drawing.Font("微软雅黑", 9),
+            Checked = false
+        };
+
+        _btnStart = new Button
+        {
+            Text = "开始录制",
+            Location = new System.Drawing.Point(20, 185),
+            Size = new System.Drawing.Size(140, 38),
+            BackColor = System.Drawing.Color.ForestGreen,
+            ForeColor = System.Drawing.Color.White,
+            FlatStyle = FlatStyle.Flat
+        };
+        _btnStart.Click += BtnStart_Click;
+
+        _btnStop = new Button
+        {
+            Text = "停止录制",
+            Location = new System.Drawing.Point(185, 185),
+            Size = new System.Drawing.Size(140, 38),
+            BackColor = System.Drawing.Color.Crimson,
+            ForeColor = System.Drawing.Color.White,
+            FlatStyle = FlatStyle.Flat,
+            Enabled = false
+        };
+        _btnStop.Click += BtnStop_Click;
+
+        _progressBar = new ProgressBar
+        {
+            Location = new System.Drawing.Point(20, 235),
+            Size = new System.Drawing.Size(305, 16),
+            Style = ProgressBarStyle.Continuous,
+            Minimum = 0, Maximum = 100, Value = 0,
+            Visible = false
+        };
+
+        _lblProgress = new Label
+        {
+            Location = new System.Drawing.Point(20, 254),
+            Size = new System.Drawing.Size(305, 18),
+            Font = new System.Drawing.Font("Consolas", 8),
+            ForeColor = System.Drawing.Color.DimGray,
+            Text = "",
+            Visible = false
+        };
+
+        _timer = new System.Windows.Forms.Timer { Interval = 1000 };
+        _timer.Tick += (_, _) => _lblTimer.Text = (DateTime.Now - _startTime).ToString(@"hh\:mm\:ss");
+
+        Controls.AddRange(new Control[]
+        {
+            _lblStatus, _lblTimer,
+            _lblFps, _trackFps, _lblFpsVal,
+            _lblQuality, _cmbQuality,
+            _chkHideCursor,
+            _btnStart, _btnStop,
+            _progressBar,
+            _lblProgress
+        });
+    }
+
+    private void BtnStart_Click(object? sender, EventArgs e)
+    {
+        var (_, qualityValue, _) = QualityOptions[_cmbQuality.SelectedIndex];
+        var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+        var videoPath = Path.Combine(_outputDir, $"recording_{timestamp}.avi");
+        _lastVideoPath = videoPath;
+
+        // H.264 模式先录无压缩 AVI,之后 ffmpeg 转码
+        // MJPEG 模式直接录 MJPEG AVI
+        int captureQuality = qualityValue <= 18 ? 101 : qualityValue; // 101 = 无压缩
+        _capture = new CaptureService(fps: _trackFps.Value, quality: captureQuality, hideCursor: _chkHideCursor.Checked);
+        _capture.Start(videoPath);
+
+        _startTime = DateTime.Now;
+        _timer.Start();
+
+        _trackFps.Enabled = false;
+        _cmbQuality.Enabled = false;
+        _chkHideCursor.Enabled = false;
+        _lblStatus.Text = $"录制中 → {Path.GetFileName(videoPath)}";
+        _btnStart.Enabled = false;
+        _btnStop.Enabled = true;
+    }
+
+    private void BtnStop_Click(object? sender, EventArgs e)
+    {
+        _timer.Stop();
+        _capture?.Stop();
+        _capture = null;
+
+        _lblTimer.Text = "00:00:00";
+        _btnStop.Enabled = false;
+
+        var aviPath = _lastVideoPath;
+        var (_, qualityValue, ffmpegArgs) = QualityOptions[_cmbQuality.SelectedIndex];
+        bool needFfmpeg = qualityValue <= 18;
+
+        if (aviPath != null && File.Exists(aviPath) && needFfmpeg)
+        {
+            var ffmpeg = FindFfmpeg();
+            if (ffmpeg != null)
+            {
+                // 先获取视频总时长(秒),用于计算进度百分比
+                double totalSeconds = GetAviDuration(ffmpeg, aviPath);
+
+                _lblStatus.Text = "转码中,请稍候...";
+                _progressBar.Value = 0;
+                _progressBar.Visible = true;
+                _lblProgress.Text = "";
+                _lblProgress.Visible = true;
+                var mp4Path = Path.ChangeExtension(aviPath, ".mp4");
+
+                Task.Run(() =>
+                {
+                    try
+                    {
+                        var psi = new System.Diagnostics.ProcessStartInfo
+                        {
+                            FileName = ffmpeg,
+                            Arguments = $"-y -i \"{aviPath}\" {ffmpegArgs} -progress pipe:1 \"{mp4Path}\"",
+                            CreateNoWindow = true,
+                            UseShellExecute = false,
+                            RedirectStandardOutput = true,
+                            RedirectStandardError = true
+                        };
+                        var proc = System.Diagnostics.Process.Start(psi)!;
+                        proc.ErrorDataReceived += (_, _) => { }; // 丢弃 stderr 避免阻塞
+                        proc.BeginErrorReadLine();
+
+                        // 逐行读取 stdout 进度
+                        string? line;
+                        while ((line = proc.StandardOutput.ReadLine()) != null)
+                        {
+                            // 格式: out_time_us=1234567
+                            if (line.StartsWith("out_time_us=") &&
+                                long.TryParse(line.AsSpan("out_time_us=".Length), out long us) &&
+                                totalSeconds > 0)
+                            {
+                                double done = us / 1_000_000.0;
+                                int pct = (int)Math.Min(100, done / totalSeconds * 100);
+                                string timeStr = TimeSpan.FromSeconds(done).ToString(@"hh\:mm\:ss");
+                                string totalStr = TimeSpan.FromSeconds(totalSeconds).ToString(@"hh\:mm\:ss");
+                                Invoke(() =>
+                                {
+                                    _progressBar.Value = pct;
+                                    _lblProgress.Text = $"{timeStr} / {totalStr}  ({pct}%)";
+                                });
+                            }
+                        }
+
+                        proc.WaitForExit();
+                        bool ok = proc.ExitCode == 0 && File.Exists(mp4Path);
+                        if (ok) File.Delete(aviPath);
+
+                        Invoke(() =>
+                        {
+                            _progressBar.Visible = false;
+                            _lblProgress.Visible = false;
+                            _trackFps.Enabled = true;
+                            _cmbQuality.Enabled = true;
+                            _chkHideCursor.Enabled = true;
+                            _btnStart.Enabled = true;
+                            if (ok)
+                            {
+                                _lblStatus.Text = $"已保存至 {_outputDir}";
+                                MessageBox.Show($"录制完成:\n{mp4Path}", "完成",
+                                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                            }
+                            else
+                            {
+                                _lblStatus.Text = "转码失败,AVI 已保留";
+                                MessageBox.Show($"转码失败,AVI 保留在:\n{aviPath}", "提示",
+                                    MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                            }
+                        });
+                    }
+                    catch (Exception ex)
+                    {
+                        Invoke(() =>
+                        {
+                            _progressBar.Visible = false;
+                            _lblProgress.Visible = false;
+                            _trackFps.Enabled = true;
+                            _cmbQuality.Enabled = true;
+                            _chkHideCursor.Enabled = true;
+                            _btnStart.Enabled = true;
+                            _lblStatus.Text = "转码出错";
+                            MessageBox.Show($"转码出错:{ex.Message}", "错误",
+                                MessageBoxButtons.OK, MessageBoxIcon.Error);
+                        });
+                    }
+                });
+                return;
+            }
+            else
+            {
+                MessageBox.Show(
+                    $"未找到 ffmpeg,无法转码。\nAVI 保留在:\n{aviPath}",
+                    "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+            }
+        }
+        else if (aviPath != null)
+        {
+            _lblStatus.Text = $"已保存至 {_outputDir}";
+            MessageBox.Show($"录制完成:\n{aviPath}", "完成",
+                MessageBoxButtons.OK, MessageBoxIcon.Information);
+        }
+
+        _trackFps.Enabled = true;
+        _cmbQuality.Enabled = true;
+        _chkHideCursor.Enabled = true;
+        _btnStart.Enabled = true;
+    }
+
+    // 用 ffprobe 思路:让 ffmpeg 读文件头获取时长
+    private static double GetAviDuration(string ffmpeg, string path)
+    {
+        try
+        {
+            var psi = new System.Diagnostics.ProcessStartInfo
+            {
+                FileName = ffmpeg,
+                Arguments = $"-i \"{path}\"",
+                CreateNoWindow = true,
+                UseShellExecute = false,
+                RedirectStandardError = true
+            };
+            var proc = System.Diagnostics.Process.Start(psi)!;
+            string err = proc.StandardError.ReadToEnd();
+            proc.WaitForExit();
+            // 解析 "Duration: HH:MM:SS.xx"
+            var m = System.Text.RegularExpressions.Regex.Match(err,
+                @"Duration:\s*(\d+):(\d+):(\d+\.\d+)");
+            if (m.Success)
+            {
+                double h = double.Parse(m.Groups[1].Value);
+                double min = double.Parse(m.Groups[2].Value);
+                double sec = double.Parse(m.Groups[3].Value);
+                return h * 3600 + min * 60 + sec;
+            }
+        }
+        catch { }
+        return 0;
+    }
+
+    protected override void OnFormClosing(FormClosingEventArgs e)
+    {
+        _capture?.Dispose();
+        base.OnFormClosing(e);
+    }
+}

+ 25 - 0
README.md

@@ -0,0 +1,25 @@
+# ScreenRecorder
+
+Windows 屏幕录制工具,基于 .NET 8 + Windows Forms,支持 H.264/H.265 硬件编码和 MJPEG 软编码。
+
+## 环境要求
+
+- Windows 10 19041+ (x64)
+- .NET 8 Runtime
+
+## ffmpeg 依赖
+
+本工具依赖 ffmpeg 进行视频编码,请自行下载 ffmpeg.exe 并放置到以下任一位置:
+
+1. 程序所在目录(与 ScreenRecorder.exe 同级)
+2. 系统 PATH 环境变量中的任意目录
+
+推荐从 [FFmpeg 官网](https://ffmpeg.org/download.html) 下载 Windows 64-bit 静态编译版本。
+
+## 构建
+
+```bash
+dotnet build -c Release
+```
+
+编译产物在 `bin/Release/net8.0-windows10.0.19041.0/win-x64/` 目录下。

+ 24 - 0
ScreenRecorder.csproj

@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
+    <UseWindowsForms>true</UseWindowsForms>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="NAudio" Version="2.2.1" />
+    <PackageReference Include="SharpAvi" Version="2.2.1" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="ffmpeg.exe">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
+</Project>