windowcapture
исходный код / Recording/VideoRecorder.cs

VideoRecorder.cs

459 строк · 20,817 байт · модуль Recording
  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}