using System.Drawing.Imaging; using System.Runtime.InteropServices; using System.Text; using anjianjingling.Vision; namespace anjianjingling.Capture { /// /// 屏幕录像:GDI 抓帧 → JPEG 编码 → 手写 AVI(MJPG) 封装,无需 ffmpeg。 /// public sealed class ScreenRecorder : IDisposable { private readonly ScreenCapture _capture = new(); private Thread? _thread; private volatile bool _running; private AviWriter? _writer; public bool IsRecording => _running; public string? OutputPath { get; private set; } public long FrameCount { get; private set; } public event Action? Finished; public int Fps { get; set; } = 15; public bool DrawCursor { get; set; } = true; public int Quality { get; set; } = 70; public void Start(string path, Rectangle region) { if (_running) return; int w = region.Width & ~1; // 编码器要求偶数宽高更稳 int h = region.Height & ~1; if (w < 16 || h < 16) throw new ArgumentException("录制区域太小"); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); OutputPath = path; FrameCount = 0; _writer = new AviWriter(path, w, h, Math.Clamp(Fps, 1, 60)); _running = true; var rect = new Rectangle(region.X, region.Y, w, h); var codec = GetJpegCodec(); var ep = new EncoderParameters(1); ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)Math.Clamp(Quality, 20, 95)); _thread = new Thread(() => { var sw = System.Diagnostics.Stopwatch.StartNew(); int frameInterval = Math.Max(1, 1000 / Math.Clamp(Fps, 1, 60)); long frameIdx = 0; while (_running) { // 固定帧率:按理论时间点抓帧,抓不动就丢帧,保证 AVI 时长等于真实时长 long target = frameIdx * frameInterval; long now = sw.ElapsedMilliseconds; if (now < target) { Thread.Sleep((int)Math.Min(30, target - now)); continue; } try { using var bmp = _capture.CaptureRegion(rect.X, rect.Y, rect.Width, rect.Height); if (DrawCursor) { using var g = Graphics.FromImage(bmp); DrawCursorOn(g, rect.X, rect.Y); } using var ms = new MemoryStream(); bmp.Save(ms, codec, ep); _writer!.AddFrame(ms.ToArray()); FrameCount++; } catch { Thread.Sleep(frameInterval); } frameIdx = Math.Max(frameIdx + 1, now / frameInterval + 1); } }) { IsBackground = true, Name = "ScreenRecorder" }; _thread.Start(); } public void Stop() { if (!_running) return; _running = false; _thread?.Join(3000); _thread = null; _writer?.Complete(); _writer?.Dispose(); _writer = null; if (OutputPath != null) Finished?.Invoke(OutputPath, FrameCount); } public void Dispose() { Stop(); } private static ImageCodecInfo GetJpegCodec() => ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid); // ===================== 光标 ===================== [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; } [StructLayout(LayoutKind.Sequential)] private struct ICONINFO { public bool fIcon; public int xHotspot; public int yHotspot; public IntPtr hbmMask; public IntPtr hbmColor; } private const int CURSOR_SHOWING = 0x00000001; private const int DI_NORMAL = 0x0003; [DllImport("user32.dll")] private static extern bool GetCursorInfo(ref CURSORINFO pci); [DllImport("user32.dll")] private static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); [DllImport("user32.dll")] private static extern bool DrawIconEx(IntPtr hdc, int xLeft, int yTop, IntPtr hIcon, int cxWidth, int cyWidth, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr hObject); private static void DrawCursorOn(Graphics g, int offsetX, int offsetY) { var ci = new CURSORINFO { cbSize = Marshal.SizeOf() }; if (!GetCursorInfo(ref ci) || (ci.flags & CURSOR_SHOWING) == 0) return; int hx = 0, hy = 0; if (GetIconInfo(ci.hCursor, out var ii)) { hx = ii.xHotspot; hy = ii.yHotspot; if (ii.hbmMask != IntPtr.Zero) DeleteObject(ii.hbmMask); if (ii.hbmColor != IntPtr.Zero) DeleteObject(ii.hbmColor); } IntPtr hdc = g.GetHdc(); try { DrawIconEx(hdc, ci.ptScreenPos.X - offsetX - hx, ci.ptScreenPos.Y - offsetY - hy, ci.hCursor, 0, 0, 0, IntPtr.Zero, DI_NORMAL); } finally { g.ReleaseHdc(hdc); } } } /// /// 最小可用 AVI(MJPG) 封装:RIFF/hdrl(avih+strl)/movi/idx1。 /// internal sealed class AviWriter : IDisposable { private readonly FileStream _fs; private readonly int _w, _h, _fps; private readonly List<(long Offset, int Size)> _index = new(); private long _riffSizePos, _moviSizePos, _moviDataStart; private long _avihFramesPos, _strhLengthPos, _avihMaxBytesPos, _avihBufPos, _strhBufPos; private int _frames; private long _maxBytes; public AviWriter(string path, int width, int height, int fps) { _w = width; _h = height; _fps = fps; _fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); WriteHeader(); } private void W(string s) => _fs.Write(Encoding.ASCII.GetBytes(s)); private void W(int v) => _fs.Write(BitConverter.GetBytes(v)); private void W(uint v) => _fs.Write(BitConverter.GetBytes(v)); private void W(short v) => _fs.Write(BitConverter.GetBytes(v)); private void WriteHeader() { W("RIFF"); _riffSizePos = _fs.Position; W(0); W("AVI "); // ---- LIST hdrl ----(size = 'hdrl' + avih块 + strl整块) const int strlContent = 4 + (8 + 56) + (8 + 40); // 'strl' + strh + strf = 116 const int strlList = 8 + strlContent; // 124 const int hdrlContent = 4 + (8 + 56) + strlList; // 192 W("LIST"); W(hdrlContent); W("hdrl"); // avih W("avih"); W(56); long avihStart = _fs.Position; W(1_000_000 / _fps); // dwMicroSecPerFrame _avihMaxBytesPos = _fs.Position; W(0); // dwMaxBytesPerSec W(0); // dwPaddingGranularity W(0x10); // dwFlags: AVIF_HASINDEX _avihFramesPos = _fs.Position; W(0); // dwTotalFrames W(0); // dwInitialFrames W(1); // dwStreams _avihBufPos = _fs.Position; W(0); // dwSuggestedBufferSize W(_w); W(_h); W(0); W(0); W(0); W(0); // reserved // ---- LIST strl ---- W("LIST"); W(strlContent); W("strl"); W("strh"); W(56); W("vids"); W("MJPG"); W(0); // dwFlags W((short)0); // wPriority W((short)0); // wLanguage W(0); // dwInitialFrames W(1); // dwScale W(_fps); // dwRate W(0); // dwStart _strhLengthPos = _fs.Position; W(0); // dwLength _strhBufPos = _fs.Position; W(0); // dwSuggestedBufferSize W(-1); // dwQuality W(0); // dwSampleSize W((short)0); W((short)0); W((short)_w); W((short)_h); // rcFrame W("strf"); W(40); W(40); // biSize W(_w); W(_h); W((short)1); // biPlanes W((short)24); // biBitCount W("MJPG"); // biCompression W(_w * _h * 3); // biSizeImage W(0); W(0); W(0); W(0); // ---- LIST movi ---- W("LIST"); _moviSizePos = _fs.Position; W(0); W("movi"); _moviDataStart = _fs.Position; } public void AddFrame(byte[] jpeg) { long chunkStart = _fs.Position; W("00dc"); W(jpeg.Length); _fs.Write(jpeg); if ((jpeg.Length & 1) == 1) _fs.WriteByte(0); _index.Add((chunkStart - _moviDataStart, jpeg.Length)); _frames++; if (jpeg.Length > _maxBytes) _maxBytes = jpeg.Length; } public void Complete() { long moviEnd = _fs.Position; long moviSize = moviEnd - _moviSizePos - 4; // 不含 'movi' 四字符 // idx1 W("idx1"); W(_index.Count * 16); foreach (var (off, size) in _index) { W("00dc"); W((uint)0x10); W((int)off); W(size); } long fileEnd = _fs.Position; // 回填各长度字段 void Patch(long pos, int val) { long save = _fs.Position; _fs.Position = pos; _fs.Write(BitConverter.GetBytes(val)); _fs.Position = save; } Patch(_riffSizePos, (int)(fileEnd - 8)); Patch(_moviSizePos, (int)moviSize); Patch(_avihFramesPos, _frames); Patch(_strhLengthPos, _frames); Patch(_avihMaxBytesPos, (int)(_maxBytes * _fps)); Patch(_avihBufPos, (int)_maxBytes); Patch(_strhBufPos, (int)_maxBytes); _fs.Flush(); } public void Dispose() => _fs.Dispose(); } }