315 lines
11 KiB
C#
315 lines
11 KiB
C#
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();
|
||
}
|
||
}
|