1using System; 2using System.Diagnostics; 3using System.Drawing; 4using System.Drawing.Imaging; 5using System.IO; 6using System.Runtime.InteropServices; 7using System.Threading; 8using System.Windows.Forms; 9using WindowCapture.Native; 10using WindowCapture.Models; 11 12namespace WindowCapture.Recording 13{ 14 public class VideoRecorder : IDisposable 15 { 16 private AviWriter aviWriter; 17 private Mp4Writer mp4Writer; 18 private bool useMp4; 19 private Rectangle captureArea; 20 private Thread captureThread; 21 private volatile bool isRunning; 22 private string outputFilePath; 23 private int fps; 24 25 // System audio 26 private IAudioClient sysAudioClient; 27 private IAudioCaptureClient sysCaptureClient; 28 private int sysSampleRate, sysChannels, sysBitsPerSample, sysBlockAlign; 29 private bool sysAudioOk; 30 31 // Microphone 32 private IAudioClient micAudioClient; 33 private IAudioCaptureClient micCaptureClient; 34 private int micSampleRate, micChannels, micBitsPerSample, micBlockAlign; 35 private bool micAudioOk; 36 37 // Output: 48kHz stereo PCM16 38 private const int OUT_RATE = 48000; 39 private const int OUT_CH = 2; 40 41 // Audio ring buffers (stereo PCM16 samples at OUT_RATE) 42 private short[] sysRing = new short[OUT_RATE * OUT_CH * 2]; // 2 sec buffer 43 private int sysRingCount; 44 private short[] micRing = new short[OUT_RATE * OUT_CH * 2]; 45 private int micRingCount; 46 private double sysFracPos, micFracPos; 47 48 // JPEG (AVI fallback only) 49 private ImageCodecInfo jpegCodec; 50 private EncoderParameters jpegParams; 51 52 [DllImport("gdi32.dll")] 53 private static extern IntPtr CreateDIBSection(IntPtr hdc, ref BITMAPINFO pbmi, uint usage, out IntPtr ppvBits, IntPtr hSection, uint offset); 54 [StructLayout(LayoutKind.Sequential)] 55 private struct BITMAPINFO 56 { 57 public int biSize, biWidth, biHeight; 58 public short biPlanes, biBitCount; 59 public int biCompression, biSizeImage, biXPelsPerMeter, biYPelsPerMeter, biClrUsed, biClrImportant; 60 } 61 62 public event Action Finished; 63 64 public VideoRecorder() 65 { 66 fps = Settings.VideoFps; 67 // JPEG codec for AVI fallback 68 foreach (var c in ImageCodecInfo.GetImageEncoders()) 69 if (c.FormatID == ImageFormat.Jpeg.Guid) { jpegCodec = c; break; } 70 jpegParams = new EncoderParameters(1); 71 jpegParams.Param[0] = new EncoderParameter( 72 System.Drawing.Imaging.Encoder.Quality, (long)Settings.VideoJpegQuality); 73 } 74 75 public void Start(Rectangle area, string filePath) 76 { 77 if (isRunning) return; 78 captureArea = area; 79 captureArea.Width = (captureArea.Width / 2) * 2; 80 captureArea.Height = (captureArea.Height / 2) * 2; 81 if (captureArea.Width < 32) captureArea.Width = 32; 82 if (captureArea.Height < 32) captureArea.Height = 32; 83 84 string ext = Path.GetExtension(filePath); 85 outputFilePath = filePath.Substring(0, filePath.Length - ext.Length) + ".mp4"; 86 string aviPath = filePath.Substring(0, filePath.Length - ext.Length) + ".avi"; 87 88 isRunning = true; 89 captureThread = new Thread(CaptureLoop); 90 captureThread.IsBackground = true; 91 captureThread.Start(); 92 } 93 94 private void Log(string msg) 95 { 96 try 97 { 98 string p = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "VideoLog.txt"); 99 File.AppendAllText(p, DateTime.Now.ToString("HH:mm:ss.fff") + " - " + msg + "\r\n"); 100 } 101 catch { } 102 } 103 104 #region Audio Init 105 106 private bool InitWasapiDevice(int dataFlow, bool loopback, 107 out IAudioClient client, out IAudioCaptureClient capture, 108 out int sr, out int ch, out int bps, out int ba) 109 { 110 client = null; capture = null; sr = 0; ch = 0; bps = 0; ba = 0; 111 try 112 { 113 var et = Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")); 114 var en = (IMMDeviceEnumerator)Activator.CreateInstance(et); 115 IMMDevice dev; 116 en.GetDefaultAudioEndpoint(dataFlow, 0, out dev); 117 Guid iid = new Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"); 118 object o; dev.Activate(ref iid, 0x17, IntPtr.Zero, out o); 119 client = (IAudioClient)o; 120 IntPtr fmtPtr; client.GetMixFormat(out fmtPtr); 121 var fmt = (WAVEFORMATEX)Marshal.PtrToStructure(fmtPtr, typeof(WAVEFORMATEX)); 122 sr = (int)fmt.nSamplesPerSec; ch = fmt.nChannels; bps = fmt.wBitsPerSample; ba = fmt.nBlockAlign; 123 int flags = loopback ? unchecked((int)0x00020000) : 0; 124 client.Initialize(0, flags, 10000000, 0, fmtPtr, IntPtr.Zero); 125 uint bf; client.GetBufferSize(out bf); 126 Guid iidC = new Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"); 127 object co; client.GetService(ref iidC, out co); 128 capture = (IAudioCaptureClient)co; 129 Marshal.FreeCoTaskMem(fmtPtr); 130 Marshal.ReleaseComObject(dev); Marshal.ReleaseComObject(en); 131 Log(string.Format("{0} OK: {1}Hz {2}ch {3}bit", loopback ? "Sys" : "Mic", sr, ch, bps)); 132 return true; 133 } 134 catch (Exception ex) 135 { 136 Log((loopback ? "Sys" : "Mic") + " FAIL: " + ex.Message); 137 if (client != null) { try { Marshal.ReleaseComObject(client); } catch { } client = null; } 138 return false; 139 } 140 } 141 142 private void DiscardBufferedAudio(IAudioCaptureClient cap) 143 { 144 try 145 { 146 uint pkt; cap.GetNextPacketSize(out pkt); 147 while (pkt > 0) 148 { 149 IntPtr d; uint n, f; ulong dp, qp; 150 cap.GetBuffer(out d, out n, out f, out dp, out qp); 151 cap.ReleaseBuffer(n); 152 cap.GetNextPacketSize(out pkt); 153 } 154 } 155 catch { } 156 } 157 158 #endregion 159 160 #region Audio Drain + Frame-locked Write 161 162 private static float ReadSample(byte[] data, int off, int bps) 163 { 164 if (bps == 32) return BitConverter.ToSingle(data, off); 165 if (bps == 16) return BitConverter.ToInt16(data, off) / 32768f; 166 return 0; 167 } 168 169 /// <summary>Drain WASAPI → resample/downmix to ring buffer (48kHz stereo)</summary> 170 private void DrainToRing(IAudioCaptureClient cap, int srcRate, int srcCh, int srcBps, int srcBA, 171 short[] ring, ref int ringCount, ref double fracPos) 172 { 173 try 174 { 175 uint pkt; cap.GetNextPacketSize(out pkt); 176 while (pkt > 0) 177 { 178 IntPtr dataPtr; uint framesAvail, flags; ulong devPos, qpcPos; 179 cap.GetBuffer(out dataPtr, out framesAvail, out flags, out devPos, out qpcPos); 180 181 double ratio = (double)srcRate / OUT_RATE; 182 183 if ((flags & 2) != 0) // SILENT 184 { 185 int outFrames = (int)((double)framesAvail / ratio); 186 int space = ring.Length - ringCount; 187 int toWrite = Math.Min(outFrames * OUT_CH, space); 188 for (int i = 0; i < toWrite; i++) ring[ringCount + i] = 0; 189 ringCount += toWrite; 190 } 191 else 192 { 193 int srcBytes = (int)(framesAvail * srcBA); 194 byte[] src = new byte[srcBytes]; 195 Marshal.Copy(dataPtr, src, 0, srcBytes); 196 int bps = srcBps / 8; 197 198 while (fracPos < framesAvail && ringCount + OUT_CH <= ring.Length) 199 { 200 int sf = (int)fracPos; 201 if (sf >= (int)framesAvail) break; 202 int boff = sf * srcBA; 203 float left = ReadSample(src, boff, srcBps); 204 float right = srcCh >= 2 ? ReadSample(src, boff + bps, srcBps) : left; 205 if (left > 1f) left = 1f; if (left < -1f) left = -1f; 206 if (right > 1f) right = 1f; if (right < -1f) right = -1f; 207 ring[ringCount++] = (short)(left * 32767f); 208 ring[ringCount++] = (short)(right * 32767f); 209 fracPos += ratio; 210 } 211 // Carry the sub-sample phase to the next packet. If the ring filled before the 212 // whole packet was consumed, the remaining source frames are dropped (the packet 213 // is released below), so keep only the fractional phase — subtracting framesAvail 214 // here would drive fracPos negative → negative buffer offset → out-of-bounds read. 215 fracPos = (fracPos >= framesAvail) ? (fracPos - framesAvail) : (fracPos - Math.Floor(fracPos)); 216 } 217 218 cap.ReleaseBuffer(framesAvail); 219 cap.GetNextPacketSize(out pkt); 220 } 221 } 222 catch { } 223 } 224 225 /// <summary>Write exactly N stereo samples of audio (locked to video frame rate). 226 /// Mix sys+mic, pad with silence if not enough data.</summary> 227 private void WriteFrameAudio(int stereoSamples) 228 { 229 // Drain both sources into ring buffers 230 if (sysAudioOk) 231 DrainToRing(sysCaptureClient, sysSampleRate, sysChannels, sysBitsPerSample, sysBlockAlign, 232 sysRing, ref sysRingCount, ref sysFracPos); 233 if (micAudioOk) 234 DrainToRing(micCaptureClient, micSampleRate, micChannels, micBitsPerSample, micBlockAlign, 235 micRing, ref micRingCount, ref micFracPos); 236 237 int totalShorts = stereoSamples * OUT_CH; // stereoSamples * 2 (L+R) 238 byte[] outBytes = new byte[totalShorts * 2]; // 2 bytes per short 239 240 for (int i = 0; i < totalShorts; i++) 241 { 242 int v = 0; 243 // Add system audio (or 0 if buffer empty) 244 if (sysAudioOk && i < sysRingCount) v += sysRing[i]; 245 // Add mic audio (or 0 if buffer empty) 246 if (micAudioOk && i < micRingCount) v += micRing[i]; 247 // Clamp 248 if (v > 32767) v = 32767; 249 if (v < -32767) v = -32767; 250 outBytes[i * 2] = (byte)(v & 0xFF); 251 outBytes[i * 2 + 1] = (byte)((v >> 8) & 0xFF); 252 } 253 254 if (useMp4) 255 mp4Writer.WriteAudioBlock(outBytes, 0, outBytes.Length); 256 else 257 aviWriter.WriteAudioBlock(outBytes, 0, outBytes.Length); 258 259 // Remove consumed samples from ring buffers 260 if (sysAudioOk) 261 { 262 int consumed = Math.Min(totalShorts, sysRingCount); 263 int rem = sysRingCount - consumed; 264 if (rem > 0) Array.Copy(sysRing, consumed, sysRing, 0, rem); 265 sysRingCount = rem; 266 } 267 if (micAudioOk) 268 { 269 int consumed = Math.Min(totalShorts, micRingCount); 270 int rem = micRingCount - consumed; 271 if (rem > 0) Array.Copy(micRing, consumed, micRing, 0, rem); 272 micRingCount = rem; 273 } 274 } 275 276 #endregion 277 278 private void DrawCursor(IntPtr hdc, int ox, int oy) 279 { 280 var ci = new WinApi.CURSORINFO(); 281 ci.cbSize = Marshal.SizeOf(typeof(WinApi.CURSORINFO)); 282 if (WinApi.GetCursorInfo(ref ci) && (ci.flags & WinApi.CURSOR_SHOWING) != 0) 283 WinApi.DrawIconEx(hdc, ci.ptScreenPos.X - ox, ci.ptScreenPos.Y - oy, ci.hCursor, 0, 0, 0, IntPtr.Zero, WinApi.DI_NORMAL); 284 } 285 286 private void CaptureLoop() 287 { 288 int w = captureArea.Width, h = captureArea.Height; 289 int cx = captureArea.X, cy = captureArea.Y; 290 int rawSize = w * h * 4; 291 292 // Init audio 293 if (Settings.RecordSystemAudio) 294 sysAudioOk = InitWasapiDevice(0, true, out sysAudioClient, out sysCaptureClient, out sysSampleRate, out sysChannels, out sysBitsPerSample, out sysBlockAlign); 295 if (Settings.RecordMicrophone) 296 micAudioOk = InitWasapiDevice(1, false, out micAudioClient, out micCaptureClient, out micSampleRate, out micChannels, out micBitsPerSample, out micBlockAlign); 297 bool hasAudio = sysAudioOk || micAudioOk; 298 299 // Audio samples per video frame (exact sync) 300 int audioSamplesPerFrame = OUT_RATE / fps; 301 302 // Try MP4/WMV first, fall back to AVI 303 useMp4 = false; 304 string aviPath = outputFilePath.Substring(0, outputFilePath.Length - 4) + ".avi"; 305 try 306 { 307 mp4Writer = new Mp4Writer(outputFilePath, w, h, fps, 8000, 308 hasAudio, OUT_RATE, OUT_CH, 16); 309 useMp4 = true; 310 // Update output path (Mp4Writer may have switched to .wmv) 311 if (mp4Writer.ActualPath != null) 312 outputFilePath = mp4Writer.ActualPath; 313 Log("=== Recording mode OK, path=" + outputFilePath); 314 } 315 catch (Exception ex) 316 { 317 Log("MP4 init failed: " + ex.Message + " — fallback AVI"); 318 if (mp4Writer != null) { mp4Writer.Dispose(); mp4Writer = null; } 319 } 320 321 if (!useMp4) 322 { 323 try 324 { 325 if (hasAudio) 326 aviWriter = new AviWriter(aviPath, w, h, fps, Settings.VideoJpegQuality, OUT_RATE, OUT_CH, 16); 327 else 328 aviWriter = new AviWriter(aviPath, w, h, fps, Settings.VideoJpegQuality); 329 } 330 catch (Exception ex) { isRunning = false; Log("AVI fail: " + ex.Message); return; } 331 } 332 333 Log(string.Format("=== REC {0}x{1}@{2} Sys={3} Mic={4} AudioPerFrame={5} MP4={6}", 334 w, h, fps, sysAudioOk, micAudioOk, audioSamplesPerFrame, useMp4)); 335 336 // Start audio AFTER all setup 337 if (sysAudioOk) try { sysAudioClient.Start(); } catch { sysAudioOk = false; } 338 if (micAudioOk) try { micAudioClient.Start(); } catch { micAudioOk = false; } 339 340 // GDI 341 IntPtr hScreenDC = WinApi.GetDC(IntPtr.Zero); 342 IntPtr hMemDC = WinApi.CreateCompatibleDC(hScreenDC); 343 var bmi = new BITMAPINFO { biSize = 40, biWidth = w, biHeight = -h, biPlanes = 1, biBitCount = 32, biSizeImage = rawSize }; 344 IntPtr dibBits; 345 IntPtr hDib = CreateDIBSection(hScreenDC, ref bmi, 0, out dibBits, IntPtr.Zero, 0); 346 IntPtr hOld = WinApi.SelectObject(hMemDC, hDib); 347 Bitmap dibBmp = useMp4 ? null : new Bitmap(w, h, w * 4, PixelFormat.Format32bppRgb, dibBits); 348 349 // Discard any audio buffered during setup 350 if (sysAudioOk) DiscardBufferedAudio(sysCaptureClient); 351 if (micAudioOk) DiscardBufferedAudio(micCaptureClient); 352 353 var sw = Stopwatch.StartNew(); 354 int written = 0, unique = 0; 355 bool hasCaptured = false; 356 357 // AVI-only: JPEG encoding 358 MemoryStream ms = useMp4 ? null : new MemoryStream(w * h); 359 byte[] lastJ = null; int lastJL = 0; 360 361 try 362 { 363 while (isRunning) 364 { 365 int expected = (int)(sw.Elapsed.TotalSeconds * fps); 366 367 // Duplicate frames to maintain timing 368 while (written < expected && hasCaptured) 369 { 370 if (useMp4) 371 mp4Writer.WriteVideoFrame(dibBits, rawSize); 372 else 373 aviWriter.WriteVideoFrame(lastJ, 0, lastJL); 374 if (hasAudio) WriteFrameAudio(audioSamplesPerFrame); 375 written++; 376 } 377 378 // Capture screen + cursor 379 WinApi.BitBlt(hMemDC, 0, 0, w, h, hScreenDC, cx, cy, WinApi.SRCCOPY); 380 DrawCursor(hMemDC, cx, cy); 381 382 if (useMp4) 383 { 384 mp4Writer.WriteVideoFrame(dibBits, rawSize); 385 } 386 else 387 { 388 ms.SetLength(0); 389 dibBmp.Save(ms, jpegCodec, jpegParams); 390 lastJL = (int)ms.Length; 391 if (lastJ == null || lastJ.Length < lastJL) lastJ = new byte[lastJL + 4096]; 392 Buffer.BlockCopy(ms.GetBuffer(), 0, lastJ, 0, lastJL); 393 aviWriter.WriteVideoFrame(lastJ, 0, lastJL); 394 } 395 396 if (hasAudio) WriteFrameAudio(audioSamplesPerFrame); 397 written++; unique++; 398 hasCaptured = true; 399 400 double wait = ((double)written / fps - sw.Elapsed.TotalSeconds) * 1000; 401 if (wait > 2) Thread.Sleep((int)wait - 1); 402 } 403 404 // Final fill 405 int fin = (int)(sw.Elapsed.TotalSeconds * fps); 406 while (written < fin && hasCaptured) 407 { 408 if (useMp4) 409 mp4Writer.WriteVideoFrame(dibBits, rawSize); 410 else 411 aviWriter.WriteVideoFrame(lastJ, 0, lastJL); 412 if (hasAudio) WriteFrameAudio(audioSamplesPerFrame); 413 written++; 414 } 415 } 416 catch (Exception ex) { Log("Error: " + ex.Message + "\n" + ex.StackTrace); } 417 finally 418 { 419 sw.Stop(); 420 double s = sw.Elapsed.TotalSeconds; 421 Log(string.Format("Done: {0} frames ({1} unique) / {2:F1}s MP4={3}", written, unique, s, useMp4)); 422 423 if (sysAudioOk) try { sysAudioClient.Stop(); } catch { } 424 if (micAudioOk) try { micAudioClient.Stop(); } catch { } 425 426 if (dibBmp != null) dibBmp.Dispose(); 427 WinApi.SelectObject(hMemDC, hOld); 428 WinApi.DeleteObject(hDib); 429 WinApi.DeleteDC(hMemDC); 430 WinApi.ReleaseDC(IntPtr.Zero, hScreenDC); 431 432 if (useMp4) 433 try { mp4Writer.Close(); } catch (Exception ex) { Log("MP4 close err: " + ex.Message); } 434 else 435 try { aviWriter.Close(); } catch (Exception ex) { Log("AVI close err: " + ex.Message); } 436 437 if (sysCaptureClient != null) try { Marshal.ReleaseComObject(sysCaptureClient); } catch { } 438 if (sysAudioClient != null) try { Marshal.ReleaseComObject(sysAudioClient); } catch { } 439 if (micCaptureClient != null) try { Marshal.ReleaseComObject(micCaptureClient); } catch { } 440 if (micAudioClient != null) try { Marshal.ReleaseComObject(micAudioClient); } catch { } 441 442 if (Finished != null) Finished(); 443 } 444 } 445 446 public void Stop() 447 { 448 isRunning = false; 449 // Wait for the capture thread to FULLY finish — its finally block flushes and closes the 450 // writer (patches MP4 moov / AVI index). A bounded 5s join let the caller move a file that 451 // was still being finalized, producing truncated/corrupt recordings. The loop exits within 452 // ~one frame of isRunning=false and finalization is bounded, so this returns promptly. 453 if (captureThread != null && captureThread.IsAlive) 454 captureThread.Join(); 455 } 456 457 public void Dispose() { Stop(); } 458 } 459}