Files
2026-10-24 13:02:19 +08:00

315 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using anjianjingling.Vision;
namespace anjianjingling.Capture
{
/// <summary>
/// 屏幕录像:GDI 抓帧 → JPEG 编码 → 手写 AVI(MJPG) 封装,无需 ffmpeg。
/// </summary>
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<string, long>? 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<CURSORINFO>() };
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); }
}
}
/// <summary>
/// 最小可用 AVI(MJPG) 封装:RIFF/hdrl(avih+strl)/movi/idx1。
/// </summary>
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();
}
}