// Screen_Record: a region of one screen recorded to H.264 in an MP4, for the mcap app's screen // recording and tools/record_probe.jai (video-capture-plan.html). Windows only. // // Two threads. The capture thread takes the screen with DXGI Desktop Duplication (dxgi_source.jai), // on a D3D11 device of its own, copies the region on the GPU, reads it back through a ring of staging // textures, so Map never waits on the GPU, and draws the cursor into it (cursor.jai). The encoder // thread hands the frames to FFmpeg (encoder.jai). Between them is a pool of RECORD_BUFFERS frames; // when the encoder falls RECORD_QUEUE frames behind, the oldest waiting is dropped and counted. // // A frame is taken at most fps times a second, and only when the screen or the cursor changed, with // one a second while nothing does, so players can seek. Each carries the time it was taken. // // recorder: Recorder; // ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0}); // ... // ok, error = record_stop(*recorder); // // fall_back says Desktop Duplication cannot run here: on a laptop with two GPUs, on a rotated screen, // or with too many duplications of the screen running. Windows.Graphics.Capture takes over then // (phase 2 of the plan). RECORD_MIN_WIDTH :: 240; // a smaller region is refused RECORD_MIN_HEIGHT :: 180; RECORD_QUEUE :: 8; // frames waiting for the encoder at most RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded Record_Screen :: struct { adapter_index, output_index: u32; adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU" name: string; // "\\.\DISPLAY1" desktop: RECT; // on the virtual desktop rotation: DXGI_MODE_ROTATION; } Record_Options :: struct { path: string; // the .mp4 written screen: s32 = -1; // into record_list_screens(); -1: the one holding region region: RECT; // on the virtual desktop; empty: the whole screen fps: s32 = 30; cursor := true; encoder: string; // "" tries RECORD_ENCODERS in turn legacy_duplicate: bool; // the plain DuplicateOutput, not DuplicateOutput1: for record-probe } Record_Stats :: struct { source: string; // "DXGI DuplicateOutput1 on " encoder: string; width, height: s32; captured: s64; // new images of the screen pointer_moves: s64; // cursor changes with no new image emitted: s64; // frames sent to be encoded repeats: s64; // of them, the once-a-second repeat of an unchanged screen written: s64; // encoded dropped: s64; // the encoder was too far behind lost: s64; // times the duplication was lost and made again copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks bytes: s64; // encoded seconds: float64; // from start to stop } Recorder :: struct { options: Record_Options; source: Dxgi_Source; encoder: Encoder; width, height: s32; frequency: s64; // QueryPerformanceCounter's start_ticks: s64; capture_thread, encoder_thread: Thread; running: bool; stopping: bool; // set by record_stop; the capture thread ends, then the encoder capture_ended: bool; mutex: Mutex; ready: Semaphore; // signalled for each frame queued, and once when capture ends frames: [RECORD_BUFFERS] Record_Frame; free: [RECORD_BUFFERS] s32; free_count: s32; queue: [RECORD_BUFFERS] s32; // oldest first queue_count: s32; last_frame: [] u8; // the last frame queued, BGRA, for record_last_frame stats: Record_Stats; error: string; // why it stopped by itself; heap } Record_Frame :: struct { pixels: [] u8; // BGRA, rows packed ticks: s64; } // Opens the screen, the encoder and the file, and starts recording. record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: string, fall_back: bool { r.* = .{}; r.options = options; r.options.path = copy_string(options.path); r.options.encoder = copy_string(options.encoder); if r.options.fps <= 0 r.options.fps = 30; screens := record_list_screens(); if !screens.count return false, "no screen to record", false; screen_index := options.screen; region := options.region; has_region := region.right > region.left && region.bottom > region.top; if screen_index < 0 { if !has_region screen_index = 0; else for screens if rect_inside(region, it.desktop) { screen_index = xx it_index; break; } if screen_index < 0 return false, "the region is not on one screen", false; } if screen_index >= screens.count return false, tprint("there is no screen %", screen_index), false; screen := screens[screen_index]; if !has_region region = screen.desktop; if !rect_inside(region, screen.desktop) return false, "the region is not on that screen", false; // The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it. // RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size. #assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0; if region.right - region.left < RECORD_MIN_WIDTH || region.bottom - region.top < RECORD_MIN_HEIGHT return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false; local := RECT.{region.left - screen.desktop.left, region.top - screen.desktop.top, region.right - screen.desktop.left, region.bottom - screen.desktop.top}; local.right = local.left + ((local.right - local.left) & cast(s32) ~1); local.bottom = local.top + ((local.bottom - local.top) & cast(s32) ~1); r.width = local.right - local.left; r.height = local.bottom - local.top; ok, error, fall_back := dxgi_open(*r.source, screen, local, options.legacy_duplicate); if !ok { dxgi_close(*r.source); return false, error, fall_back; } tried: string; ok, error, tried = encoder_open(*r.encoder, r.options.path, r.width, r.height, r.options.fps, r.options.encoder); if tried log("[record] Encoders that did not open: %", tried); if !ok { dxgi_close(*r.source); return false, error, false; } r.stats.source = sprint("DXGI % on %", r.source.method, screen.adapter); r.stats.encoder = copy_string(r.encoder.name); r.stats.width, r.stats.height = r.width, r.height; init(*r.mutex, "record"); init(*r.ready); for * r.frames it.pixels = NewArray(cast(s64) r.width * r.height * 4, u8, initialized = false); for 0..RECORD_BUFFERS - 1 r.free[it] = cast(s32) it; r.free_count = RECORD_BUFFERS; r.last_frame = NewArray(cast(s64) r.width * r.height * 4, u8); frequency: LARGE_INTEGER; QueryPerformanceFrequency(*frequency); r.frequency = frequency.QuadPart; r.start_ticks = ticks_now(); thread_init(*r.capture_thread, record_capture_proc, name = "record capture"); thread_init(*r.encoder_thread, record_encoder_proc, name = "record encoder"); r.running = true; thread_start(*r.capture_thread, r); thread_start(*r.encoder_thread, r); return true, "", false; } // Stops, finishes the file and frees everything. ok is false when recording stopped by itself on an // error, or the file could not be finished; what was recorded up to then is in the file all the same. record_stop :: (r: *Recorder) -> ok: bool, error: string { if !r.running return false, "not recording"; atomic_swap(*r.stopping, true); thread_is_done(*r.capture_thread, -1); thread_is_done(*r.encoder_thread, -1); thread_deinit(*r.capture_thread); thread_deinit(*r.encoder_thread); r.running = false; closed, close_error := encoder_close(*r.encoder); r.stats.seconds = cast(float64) (ticks_now() - r.start_ticks) / r.frequency; r.stats.bytes = r.encoder.bytes; dxgi_close(*r.source); for * r.frames array_free(it.pixels); destroy(*r.ready); destroy(*r.mutex); if r.error return false, r.error; if !closed return false, close_error; return true, ""; } // As they are now; strings are the recorder's. record_stats :: (r: *Recorder) -> Record_Stats { if !r.running return r.stats; lock(*r.mutex); defer unlock(*r.mutex); return r.stats; } // The last frame queued, BGRA (temp), cursor and all. record_last_frame :: (r: *Recorder) -> pixels: [] u8, width: s32, height: s32 { if r.running lock(*r.mutex); pixels := array_copy(r.last_frame,, temp); if r.running unlock(*r.mutex); return pixels, r.width, r.height; } // Whether recording stopped by itself (the screen went away, the encoder failed); why, then. record_failed :: (r: *Recorder) -> bool, string { lock(*r.mutex); defer unlock(*r.mutex); return r.error.count > 0, r.error; } #scope_file Staged :: struct { pending: bool; ticks: s64; pointer: POINT; pointer_visible: bool; } record_capture_proc :: (thread: *Thread) -> s64 { r := cast(*Recorder) thread.data; s := *r.source; // A change is sent as soon as it comes once its turn is due: turns are a frame's time apart on a // schedule that moves on a frame at a time, so the rate never passes fps, and a tenth of a frame // early counts as on time, so a 60 Hz screen's frames, which jitter, all make a 60 fps cap. period := r.frequency / r.options.fps; early := period / 10; turn := ticks_now(); last_emit: s64 = -r.frequency; have_image, changed := false, false; staged: [RECORD_STAGING] Staged; emits: s64; while !atomic_read(*r.stopping) { now := ticks_now(); due := ifx changed then turn - early else last_emit + r.frequency; // the change, or the repeat wait := clamp((due - now) * 1000 / r.frequency, 0, 100); next, error := dxgi_next(s, cast(u32) wait); if next == { case .FAILED; record_fail(r, error); break; case .LOST; // A mode change, the secure desktop, a game going fullscreen: duplicated again, and on. lock(*r.mutex); r.stats.lost += 1; unlock(*r.mutex); while !atomic_read(*r.stopping) { hr := dxgi_duplicate(s); if SUCCEEDED(hr) break; if !hresult_is(hr, DXGI_ERROR_ACCESS_LOST) && !hresult_is(hr, 0x80070005) && !hresult_is(hr, DXGI_ERROR_NOT_CURRENTLY_AVAILABLE) { record_fail(r, hresult_text("the screen could not be duplicated again", hr)); break; } sleep_milliseconds(250); } if atomic_read(*r.stopping) || r.error break; continue; case .IMAGE; #through; case .IMAGE_AND_POINTER; have_image, changed = true, true; lock(*r.mutex); r.stats.captured += 1; r.stats.copy_ticks = s.copy_ticks; unlock(*r.mutex); case .POINTER; if r.options.cursor && have_image { changed = true; lock(*r.mutex); r.stats.pointer_moves += 1; unlock(*r.mutex); } } now = ticks_now(); if !have_image continue; repeat := !changed && now - last_emit >= r.frequency; if !(changed && now >= turn - early) && !repeat continue; turn = max(turn + period, now - period); // after a quiet spell, no burst to catch up // Copied to staging now; read back two frames on, when the GPU is done with it. slot := emits % RECORD_STAGING; if staged[slot].pending record_read_back(r, slot, staged[slot]); ID3D11DeviceContext_CopyResource(s.device_context, s.staging[slot], s.latest); staged[slot] = .{pending = true, ticks = now, pointer = s.pointer_position, pointer_visible = s.pointer_visible}; emits += 1; last_emit = now; changed = false; lock(*r.mutex); r.stats.emitted += 1; if repeat r.stats.repeats += 1; unlock(*r.mutex); } // What is still staged, oldest first. for 0..RECORD_STAGING - 1 { slot := (emits + it) % RECORD_STAGING; if staged[slot].pending record_read_back(r, slot, staged[slot]); } atomic_swap(*r.capture_ended, true); signal(*r.ready); return 0; } // Copies a staging texture into a free frame, draws the cursor and queues it. record_read_back :: (r: *Recorder, slot: s64, staged: Staged) { s := *r.source; t := ticks_now(); lock(*r.mutex); if !r.free_count { // every frame is waiting: drop the oldest r.free[r.free_count] = r.queue[0]; r.free_count += 1; for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it]; r.queue_count -= 1; r.stats.dropped += 1; } r.free_count -= 1; index := r.free[r.free_count]; unlock(*r.mutex); frame := *r.frames[index]; mapped: D3D11_MAPPED_SUBRESOURCE; hr := ID3D11DeviceContext_Map(s.device_context, s.staging[slot], 0, .READ, 0, *mapped); if FAILED(hr) { lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; unlock(*r.mutex); record_fail(r, hresult_text("reading the frame back", hr)); return; } row := cast(s64) r.width * 4; for y: 0..r.height - 1 memcpy(frame.pixels.data + y * row, cast(*u8) mapped.pData + cast(s64) y * mapped.RowPitch, row); ID3D11DeviceContext_Unmap(s.device_context, s.staging[slot], 0); if r.options.cursor && staged.pointer_visible { cursor_draw(frame.pixels.data, r.width, r.height, *s.shape, staged.pointer.x - cast(s32) s.box.left, staged.pointer.y - cast(s32) s.box.top); } frame.ticks = staged.ticks; lock(*r.mutex); r.queue[r.queue_count] = index; r.queue_count += 1; memcpy(r.last_frame.data, frame.pixels.data, frame.pixels.count); r.stats.readback_ticks += ticks_now() - t; unlock(*r.mutex); signal(*r.ready); } record_encoder_proc :: (thread: *Thread) -> s64 { r := cast(*Recorder) thread.data; first: s64 = -1; while true { wait_for(*r.ready, 100); lock(*r.mutex); index: s32 = -1; if r.queue_count { index = r.queue[0]; for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it]; r.queue_count -= 1; } unlock(*r.mutex); if index < 0 { if atomic_read(*r.capture_ended) break; continue; } frame := *r.frames[index]; if first < 0 first = frame.ticks; pts := (frame.ticks - first) * 90000 / r.frequency; t := ticks_now(); ok, error := encoder_write(*r.encoder, frame.pixels.data, pts); lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; r.stats.encode_ticks += ticks_now() - t; if ok r.stats.written += 1; r.stats.bytes = r.encoder.bytes; unlock(*r.mutex); if !ok { record_fail(r, error); // Frames still come until the capture thread sees it; they are let go. while !atomic_read(*r.capture_ended) { wait_for(*r.ready, 100); lock(*r.mutex); for 0..r.queue_count - 1 { r.free[r.free_count] = r.queue[it]; r.free_count += 1; } r.queue_count = 0; unlock(*r.mutex); } break; } } return 0; } // Ends the recording from one of its threads; the first reason is kept. record_fail :: (r: *Recorder, error: string) { log_error("[record] %", error); lock(*r.mutex); if !r.error r.error = copy_string(error); unlock(*r.mutex); atomic_swap(*r.stopping, true); } ticks_now :: () -> s64 { t: LARGE_INTEGER; QueryPerformanceCounter(*t); return t.QuadPart; } rect_inside :: (inner: RECT, outer: RECT) -> bool { return inner.left >= outer.left && inner.top >= outer.top && inner.right <= outer.right && inner.bottom <= outer.bottom; } #scope_export #load "dxgi_source.jai"; #load "encoder.jai"; #load "cursor.jai"; #import "Basic"; #import "String"; #import "Atomics"; #import "Thread"; #import "Windows"; #import "Windows_Utf8"; #import "dxgi"; #import "d3d11"; #import "ffmpeg";