first commit
This commit is contained in:
commit
761b95080f
404 files changed
+312608
No files matched your search
@@ -0,0 +1,524 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace anjianjingling.Capture
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作录制器:WH_KEYBOARD_LL / WH_MOUSE_LL 低级钩子采集真实键鼠事件,
|
||||
/// 结束时聚合为可直接回放的 Lua 脚本。
|
||||
/// </summary>
|
||||
public sealed class InputRecorder
|
||||
{
|
||||
// ===================== Win32 =====================
|
||||
private const int WH_KEYBOARD_LL = 13;
|
||||
private const int WH_MOUSE_LL = 14;
|
||||
|
||||
private const int WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101;
|
||||
private const int WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105;
|
||||
private const int WM_MOUSEMOVE = 0x0200;
|
||||
private const int WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202;
|
||||
private const int WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205;
|
||||
private const int WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208;
|
||||
private const int WM_MOUSEWHEEL = 0x020A;
|
||||
private const int WM_QUIT = 0x0012;
|
||||
|
||||
private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KBDLLHOOKSTRUCT
|
||||
{
|
||||
public uint vkCode;
|
||||
public uint scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT { public int X, Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSLLHOOKSTRUCT
|
||||
{
|
||||
public POINT pt;
|
||||
public uint mouseData;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetKeyboardState(byte[] lpKeyState);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short GetKeyState(int nVirtKey);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int ToUnicodeEx(uint wVirtKey, uint wScanCode, byte[] lpKeyState,
|
||||
[Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pwszBuff, int cchBuff, uint wFlags, IntPtr dwhkl);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetKeyboardLayout(uint idThread);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetKeyboardLayoutList(int nBuff, [Out] IntPtr[]? lpList);
|
||||
|
||||
// ===================== 事件模型 =====================
|
||||
private enum Kind { Move, ButtonDown, ButtonUp, Wheel, KeyDown, KeyUp }
|
||||
|
||||
private sealed class RecEvent
|
||||
{
|
||||
public long T;
|
||||
public Kind Kind;
|
||||
public int X, Y;
|
||||
public string Btn = "left";
|
||||
public int Vk;
|
||||
public string KeyName = "";
|
||||
public string? Ch;
|
||||
public int Wheel;
|
||||
|
||||
public bool IsPrintable => Ch is { Length: > 0 };
|
||||
}
|
||||
|
||||
private readonly List<RecEvent> _events = new();
|
||||
private readonly object _lock = new();
|
||||
private long _t0;
|
||||
private Thread? _thread;
|
||||
private uint _threadId;
|
||||
private IntPtr _kbHook, _msHook;
|
||||
private HookProc? _kbProc, _msProc;
|
||||
private readonly ManualResetEventSlim _ready = new(false);
|
||||
private volatile bool _recording;
|
||||
|
||||
public bool IsRecording => _recording;
|
||||
public event Action<bool>? StateChanged;
|
||||
|
||||
/// <summary>录制鼠标移动轨迹(关闭后只记录点击)。</summary>
|
||||
public bool RecordMouseMove { get; set; } = true;
|
||||
|
||||
/// <summary>把连续可打印字符聚合成 typeText("...")。</summary>
|
||||
public bool SmartText { get; set; } = true;
|
||||
|
||||
/// <summary>生成脚本时单次延时上限(毫秒),超过则截断。</summary>
|
||||
public int MaxDelayMs { get; set; } = 2000;
|
||||
|
||||
/// <summary>录制期间忽略的虚拟键(默认吞掉本程序热键 F8~F12)。</summary>
|
||||
public HashSet<int> IgnoredVk { get; } = new() { 0x77, 0x78, 0x79, 0x7A, 0x7B };
|
||||
|
||||
// ===================== 开始 / 停止 =====================
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_recording) return;
|
||||
lock (_lock) _events.Clear();
|
||||
_t0 = Environment.TickCount64;
|
||||
_recording = true;
|
||||
|
||||
_thread = new Thread(HookThread)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "InputRecorderHook"
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
_ready.Wait(2000);
|
||||
StateChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
private void HookThread()
|
||||
{
|
||||
_threadId = GetCurrentThreadId();
|
||||
_kbProc = KbProc;
|
||||
_msProc = MsProc;
|
||||
IntPtr hMod = GetModuleHandle(null);
|
||||
_kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hMod, 0);
|
||||
_msHook = SetWindowsHookEx(WH_MOUSE_LL, _msProc, hMod, 0);
|
||||
_ready.Set();
|
||||
|
||||
while (GetMessage(out MSG msg, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
|
||||
if (_kbHook != IntPtr.Zero) UnhookWindowsHookEx(_kbHook);
|
||||
if (_msHook != IntPtr.Zero) UnhookWindowsHookEx(_msHook);
|
||||
_kbHook = _msHook = IntPtr.Zero;
|
||||
}
|
||||
|
||||
private IntPtr KbProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0 && _recording)
|
||||
{
|
||||
try
|
||||
{
|
||||
var kb = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
|
||||
int msg = wParam.ToInt32();
|
||||
bool down = msg is WM_KEYDOWN or WM_SYSKEYDOWN;
|
||||
bool up = msg is WM_KEYUP or WM_SYSKEYUP;
|
||||
if ((down || up) && !IgnoredVk.Contains((int)kb.vkCode))
|
||||
{
|
||||
var e = new RecEvent
|
||||
{
|
||||
T = Environment.TickCount64 - _t0,
|
||||
Kind = down ? Kind.KeyDown : Kind.KeyUp,
|
||||
Vk = (int)kb.vkCode,
|
||||
KeyName = KeyName((int)kb.vkCode)
|
||||
};
|
||||
if (down)
|
||||
{
|
||||
var ch = ToChar((int)kb.vkCode, (int)kb.scanCode);
|
||||
if (ch.HasValue && !CtrlAltDown()) e.Ch = ch.Value.ToString();
|
||||
}
|
||||
lock (_lock) _events.Add(e);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return CallNextHookEx(_kbHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
private IntPtr MsProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0 && _recording)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ms = Marshal.PtrToStructure<MSLLHOOKSTRUCT>(lParam);
|
||||
int msg = wParam.ToInt32();
|
||||
var e = new RecEvent
|
||||
{
|
||||
T = Environment.TickCount64 - _t0,
|
||||
X = ms.pt.X,
|
||||
Y = ms.pt.Y
|
||||
};
|
||||
|
||||
switch (msg)
|
||||
{
|
||||
case WM_MOUSEMOVE:
|
||||
if (!RecordMouseMove) break;
|
||||
e.Kind = Kind.Move;
|
||||
lock (_lock)
|
||||
{
|
||||
// 采样节流:同位置或 15ms 内的移动丢弃
|
||||
if (_events.Count > 0)
|
||||
{
|
||||
var last = _events[^1];
|
||||
if (last.Kind == Kind.Move)
|
||||
{
|
||||
if (e.T - last.T < 15 && Math.Abs(e.X - last.X) < 3 && Math.Abs(e.Y - last.Y) < 3)
|
||||
break;
|
||||
}
|
||||
}
|
||||
_events.Add(e);
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_LBUTTONDOWN:
|
||||
case WM_RBUTTONDOWN:
|
||||
case WM_MBUTTONDOWN:
|
||||
e.Kind = Kind.ButtonDown;
|
||||
e.Btn = msg == WM_RBUTTONDOWN ? "right" : msg == WM_MBUTTONDOWN ? "middle" : "left";
|
||||
lock (_lock) _events.Add(e);
|
||||
break;
|
||||
|
||||
case WM_LBUTTONUP:
|
||||
case WM_RBUTTONUP:
|
||||
case WM_MBUTTONUP:
|
||||
e.Kind = Kind.ButtonUp;
|
||||
e.Btn = msg == WM_RBUTTONUP ? "right" : msg == WM_MBUTTONUP ? "middle" : "left";
|
||||
lock (_lock) _events.Add(e);
|
||||
break;
|
||||
|
||||
case WM_MOUSEWHEEL:
|
||||
e.Kind = Kind.Wheel;
|
||||
e.Wheel = (short)((ms.mouseData >> 16) & 0xFFFF) / 120;
|
||||
lock (_lock) _events.Add(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return CallNextHookEx(_msHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
/// <summary>停止录制并生成 Lua 脚本。</summary>
|
||||
public string Stop()
|
||||
{
|
||||
if (!_recording) return "";
|
||||
_recording = false;
|
||||
StateChanged?.Invoke(false);
|
||||
|
||||
if (_threadId != 0) PostThreadMessage(_threadId, WM_QUIT, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread?.Join(1500);
|
||||
_thread = null;
|
||||
_threadId = 0;
|
||||
|
||||
List<RecEvent> snapshot;
|
||||
lock (_lock) snapshot = new List<RecEvent>(_events);
|
||||
return Generate(snapshot);
|
||||
}
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
if (!_recording) return;
|
||||
_recording = false;
|
||||
StateChanged?.Invoke(false);
|
||||
if (_threadId != 0) PostThreadMessage(_threadId, WM_QUIT, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread?.Join(1500);
|
||||
_thread = null;
|
||||
_threadId = 0;
|
||||
lock (_lock) _events.Clear();
|
||||
}
|
||||
|
||||
// ===================== 键名 / 字符 =====================
|
||||
|
||||
private static bool CtrlAltDown()
|
||||
=> (GetKeyState(0x11) & 0x8000) != 0 || (GetKeyState(0x12) & 0x8000) != 0;
|
||||
|
||||
private static char? ToChar(int vk, int scan)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = new byte[256];
|
||||
if (!GetKeyboardState(state)) return null;
|
||||
var sb = new StringBuilder(8);
|
||||
var layout = GetKeyboardLayout(0);
|
||||
int n = ToUnicodeEx((uint)vk, (uint)scan, state, sb, sb.Length, 0, layout);
|
||||
if (n == 1 && sb.Length > 0)
|
||||
{
|
||||
char c = sb[0];
|
||||
if (c >= 32 && c != 127) return c;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<int, string> VkNames = new()
|
||||
{
|
||||
[0x0D] = "ENTER", [0x1B] = "ESC", [0x20] = "SPACE", [0x09] = "TAB",
|
||||
[0x08] = "BACKSPACE", [0x2E] = "DELETE", [0x2D] = "INSERT",
|
||||
[0x24] = "HOME", [0x23] = "END", [0x21] = "PAGEUP", [0x22] = "PAGEDOWN",
|
||||
[0x25] = "LEFT", [0x26] = "UP", [0x27] = "RIGHT", [0x28] = "DOWN",
|
||||
[0x11] = "CTRL", [0x12] = "ALT", [0x10] = "SHIFT", [0x5B] = "WIN",
|
||||
[0x14] = "CAPSLOCK", [0x90] = "NUMLOCK",
|
||||
[0x6A] = "MULTIPLY", [0x6B] = "ADD", [0x6D] = "SUBTRACT", [0x6E] = "DECIMAL", [0x6F] = "DIVIDE",
|
||||
[0xBA] = "SEMICOLON", [0xBB] = "EQUAL", [0xBC] = "COMMA", [0xBD] = "MINUS",
|
||||
[0xBE] = "PERIOD", [0xBF] = "SLASH", [0xC0] = "BACKQUOTE",
|
||||
[0xDB] = "LBRACKET", [0xDC] = "BACKSLASH", [0xDD] = "RBRACKET", [0xDE] = "QUOTE",
|
||||
};
|
||||
|
||||
private static string KeyName(int vk)
|
||||
{
|
||||
if (VkNames.TryGetValue(vk, out var n)) return n;
|
||||
if (vk >= 0x70 && vk <= 0x87) return "F" + (vk - 0x6F);
|
||||
if (vk >= 0x60 && vk <= 0x69) return "NUMPAD" + (vk - 0x60);
|
||||
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString();
|
||||
if (vk >= 0x41 && vk <= 0x5A) return ((char)vk).ToString();
|
||||
return "VK" + vk.ToString("X2");
|
||||
}
|
||||
|
||||
private static bool IsModifier(int vk) => vk is 0x11 or 0x12 or 0x10 or 0x5B or 0x5C
|
||||
or 0xA0 or 0xA1 or 0xA2 or 0xA3 or 0xA4 or 0xA5;
|
||||
|
||||
private static string ModName(int vk) => vk switch
|
||||
{
|
||||
0x11 or 0xA2 or 0xA3 => "Ctrl",
|
||||
0x12 or 0xA4 or 0xA5 => "Alt",
|
||||
0x10 or 0xA0 or 0xA1 => "Shift",
|
||||
0x5B or 0x5C => "Win",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
// ===================== Lua 生成 =====================
|
||||
|
||||
private string Generate(List<RecEvent> ev)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("-- 由操作录制生成(" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ")");
|
||||
sb.AppendLine("-- 修改坐标/延时后可直接运行;模板图片放 images/ 目录");
|
||||
sb.AppendLine();
|
||||
|
||||
long lastT = 0;
|
||||
var mods = new List<string>();
|
||||
var textBuf = new StringBuilder();
|
||||
int i = 0;
|
||||
|
||||
void FlushText()
|
||||
{
|
||||
if (textBuf.Length == 0) return;
|
||||
sb.AppendLine($"typeText(\"{Escape(textBuf.ToString())}\")");
|
||||
textBuf.Clear();
|
||||
}
|
||||
|
||||
void EmitDelay(long t)
|
||||
{
|
||||
long gap = t - lastT;
|
||||
if (gap < 30) return;
|
||||
if (gap > MaxDelayMs) gap = MaxDelayMs;
|
||||
FlushText();
|
||||
sb.AppendLine($"delay({gap})");
|
||||
lastT = t;
|
||||
}
|
||||
|
||||
while (i < ev.Count)
|
||||
{
|
||||
var e = ev[i];
|
||||
|
||||
switch (e.Kind)
|
||||
{
|
||||
case Kind.Move:
|
||||
{
|
||||
// 合并连续移动,只保留最终落点
|
||||
int j = i;
|
||||
while (j + 1 < ev.Count && ev[j + 1].Kind == Kind.Move) j++;
|
||||
var end = ev[j];
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"moveTo({end.X}, {end.Y})");
|
||||
lastT = end.T;
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
case Kind.ButtonDown:
|
||||
{
|
||||
// 找 500ms 内的同名弹起 → 单击
|
||||
int j = i + 1;
|
||||
RecEvent? up = null;
|
||||
while (j < ev.Count)
|
||||
{
|
||||
if (ev[j].Kind == Kind.ButtonUp && ev[j].Btn == e.Btn && ev[j].T - e.T < 500) { up = ev[j]; break; }
|
||||
if (ev[j].Kind == Kind.ButtonDown) break;
|
||||
j++;
|
||||
}
|
||||
EmitDelay(e.T);
|
||||
string fn = e.Btn switch { "right" => "rightClick", "middle" => "middleClick", _ => "leftClick" };
|
||||
if (up != null)
|
||||
{
|
||||
sb.AppendLine($"{fn}({e.X}, {e.Y})");
|
||||
lastT = up.T;
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
sb.AppendLine($"moveTo({e.X}, {e.Y})");
|
||||
sb.AppendLine($"mouse.down(\"{e.Btn}\")");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
case Kind.ButtonUp:
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"mouse.up(\"{e.Btn}\")");
|
||||
i++;
|
||||
continue;
|
||||
|
||||
case Kind.Wheel:
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"wheel({e.Wheel})");
|
||||
i++;
|
||||
continue;
|
||||
|
||||
case Kind.KeyDown:
|
||||
{
|
||||
if (IsModifier(e.Vk))
|
||||
{
|
||||
string m = ModName(e.Vk);
|
||||
if (m.Length > 0 && !mods.Contains(m)) mods.Add(m);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找对应弹起
|
||||
int j = i + 1;
|
||||
while (j < ev.Count && !(ev[j].Kind == Kind.KeyUp && ev[j].Vk == e.Vk)) j++;
|
||||
bool paired = j < ev.Count;
|
||||
|
||||
if (mods.Count > 0 && mods.Any(m => m is "Ctrl" or "Alt" or "Win"))
|
||||
{
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"hotkey(\"{string.Join("+", mods.Append(e.KeyName))}\")");
|
||||
// 跳过本次按下到弹起之间的所有事件
|
||||
i = paired ? j + 1 : i + 1;
|
||||
lastT = paired ? ev[j].T : e.T;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SmartText && e.IsPrintable)
|
||||
{
|
||||
textBuf.Append(e.Ch);
|
||||
i = paired ? j + 1 : i + 1;
|
||||
lastT = paired ? ev[j].T : e.T;
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"keyPress(\"{e.KeyName}\")");
|
||||
i = paired ? j + 1 : i + 1;
|
||||
lastT = paired ? ev[j].T : e.T;
|
||||
continue;
|
||||
}
|
||||
|
||||
case Kind.KeyUp:
|
||||
// 修饰键弹起
|
||||
if (IsModifier(e.Vk))
|
||||
{
|
||||
string m = ModName(e.Vk);
|
||||
if (m.Length > 0) mods.Remove(m);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
EmitDelay(e.T);
|
||||
sb.AppendLine($"keyUp(\"{e.KeyName}\")");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
FlushText();
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string s)
|
||||
=> s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user