mcap/modules/Screen_Record/encoder.jai
Musa Mahmood e0eaf5642b [0.1.7] Recordings go into the library as regular MP4s, with a keyframe every 2 s
- A recording killed mid-way had wrong timestamps in players: its fragmented MP4's header gives a
  length of 0, and one cut short never gets the index a clean stop writes. modules/Screen_Record's
  record_remux now copies every recording's packets, as they are, into a regular MP4 (one header
  first with the length and every frame's index; faststart) before it goes into the library;
  should that fail, the fragmented MP4 goes in as it was.
- Keyframes were every 60 frames, which, with frames written only on change, could span minutes
  of time, and a crash could lose a fragment that long. One is now forced every 2 s of time
  (forced-idr for NVENC and libx264).
- A crash's leftover no longer claims a region at 0, 0 in its capture.json.
- record-probe -remux IN OUT. Tests: a recording cut at its last fragment, as a crash cuts it,
  goes in as a regular MP4 of the time before the cut.
- Checked on the recording that showed it: its MP4, cut mid-fragment, rewrites to 76 frames
  (5.2 s) with positive frame durations. A 12 s still recording now fragments every ~2 s, and
  the app killed 10 s into a recording leaves 8.1 s.
2026-10-10 07:54:48 +01:00

224 lines
8.9 KiB
Plaintext

// The encoder thread's half: frames of BGRA pixels to H.264 in a fragmented MP4, with FFmpeg
// (modules/ffmpeg). The first encoder in RECORD_ENCODERS that opens at the size wanted is used:
// NVENC, AMD's AMF, Intel's QSV, Windows' Media Foundation, then libx264 on the CPU. NVENC takes the
// BGRA as it is and converts it on the GPU; the others are given NV12 or YUV 4:2:0, converted with
// swscale (BT.709, limited range, as tagged).
//
// The file is written fragmented, a fragment at each keyframe, so one cut short by a crash still plays
// up to its last fragment. Frames are written only when the screen changes, so a count of frames can
// span any time: a keyframe is made every RECORD_KEYFRAME_SECONDS of time instead, which bounds what a
// crash loses. remux.jai makes the file a regular MP4 once it is done.
RECORD_ENCODERS :: string.["h264_nvenc", "h264_amf", "h264_qsv", "h264_mf", "libx264"];
RECORD_KEYFRAME_SECONDS :: 2;
Encoder :: struct {
format: *AVFormatContext;
codec: *AVCodecContext;
stream: *AVStream;
frame: *AVFrame;
packet: *AVPacket;
sws: *SwsContext;
name: string; // the encoder used
input: AVPixelFormat; // what it is given: BGR0 as captured, or converted to NV12 or YUV420P
width, height: s32;
last_pts: s64 = -1;
last_key_pts: s64 = -1; // the last keyframe asked for
header_written: bool;
bytes: s64; // of packets written
}
// Opens the file and the first encoder that will take width x height (wanted alone, when given).
// Failures to open an encoder are listed in tried (temp), for the log.
encoder_open :: (e: *Encoder, path: string, width: s32, height: s32, fps: s32, wanted: string) -> ok: bool, error: string, tried: string {
av_log_set_level(AV_LOG_ERROR);
e.width, e.height = width, height;
result := avformat_alloc_output_context2(*e.format, null, "mp4", temp_c_string(path));
if result < 0 || !e.format return false, av_error("FFmpeg cannot make an MP4", result), "";
tried: [..] string;
tried.allocator = temp;
names: [] string = RECORD_ENCODERS;
if wanted names = .[wanted];
for names {
ok, why := encoder_try(e, it, width, height, fps);
if ok break;
array_add(*tried, tprint("%: %", it, why));
}
tried_text := join(..tried, "; ",, temp);
if !e.codec {
encoder_free(e);
return false, tprint("no H.264 encoder opened (%)", tried_text), tried_text;
}
e.stream = avformat_new_stream(e.format, null);
if !e.stream { encoder_free(e); return false, "FFmpeg cannot add a stream", tried_text; }
avcodec_parameters_from_context(e.stream.codecpar, e.codec);
e.stream.time_base = e.codec.time_base;
result = avio_open(*e.format.pb, temp_c_string(path), AVIO_FLAG_WRITE);
if result < 0 { encoder_free(e); return false, av_error(tprint("FFmpeg cannot write %", path), result), tried_text; }
options: *AVDictionary;
av_dict_set(*options, "movflags", "frag_keyframe+empty_moov+default_base_moof", 0);
result = avformat_write_header(e.format, *options);
av_dict_free(*options);
if result < 0 { encoder_free(e); return false, av_error("FFmpeg cannot start the MP4", result), tried_text; }
e.header_written = true;
e.packet = av_packet_alloc();
e.frame = av_frame_alloc();
if !e.packet || !e.frame { encoder_free(e); return false, "FFmpeg is out of memory", tried_text; }
if e.input != .BGR0 {
e.sws = sws_getContext(width, height, .BGR0, width, height, e.input, SWS_POINT, null, null, null);
if !e.sws { encoder_free(e); return false, "swscale cannot convert to the encoder's format", tried_text; }
coefficients := cast(*[4] s32) sws_getCoefficients(SWS_CS_ITU709);
sws_setColorspaceDetails(e.sws, coefficients, 1, coefficients, 0, 0, 1 << 16, 1 << 16);
}
return true, "", tried_text;
}
// Encodes one frame; pts in RECORD_TIME_BASE ticks, made later than the last one if it is not.
encoder_write :: (e: *Encoder, pixels: *u8, pts: s64) -> ok: bool, error: string {
pts_out := max(pts, e.last_pts + 1);
e.last_pts = pts_out;
frame := e.frame;
av_frame_unref(frame);
frame.format = cast(s32) e.input;
frame.width = e.width;
frame.height = e.height;
if e.input == .BGR0 {
// Not reference-counted: avcodec_send_frame copies it, so the buffer goes back to the pool at once.
frame.data[0] = pixels;
frame.linesize[0] = e.width * 4;
} else {
result := av_frame_get_buffer(frame, 0);
if result < 0 return false, av_error("FFmpeg cannot make a frame", result);
source := pixels;
stride := e.width * 4;
sws_scale(e.sws, *source, *stride, 0, e.height, frame.data.data, frame.linesize.data);
}
frame.pts = pts_out;
if e.last_key_pts < 0 || pts_out - e.last_key_pts >= RECORD_KEYFRAME_SECONDS * RECORD_TIME_BASE {
frame.pict_type = .I; // an IDR, with forced-idr: a new fragment
e.last_key_pts = pts_out;
}
result := avcodec_send_frame(e.codec, frame);
av_frame_unref(frame);
if result < 0 return false, av_error(tprint("% cannot encode", e.name), result);
drained, error := encoder_drain(e);
return drained, error;
}
// Flushes the encoder, finishes the file and closes it.
encoder_close :: (e: *Encoder) -> ok: bool, error: string {
ok, error := true, "";
if e.codec && e.header_written {
avcodec_send_frame(e.codec, null);
ok, error = encoder_drain(e);
result := av_write_trailer(e.format);
if ok && result < 0 ok, error = false, av_error("FFmpeg cannot finish the MP4", result);
}
encoder_free(e);
return ok, error;
}
#scope_file
RECORD_TIME_BASE :: 90000; // pts ticks a second: MPEG's
encoder_try :: (e: *Encoder, name: string, width: s32, height: s32, fps: s32) -> ok: bool, error: string {
codec := avcodec_find_encoder_by_name(temp_c_string(name));
if !codec return false, "not in this FFmpeg";
input := encoder_input_format(codec);
if input == .NONE return false, "takes no format this can give it";
c := avcodec_alloc_context3(codec);
if !c return false, "out of memory";
c.width = width;
c.height = height;
c.time_base = .{1, RECORD_TIME_BASE};
c.framerate = .{fps, 1};
c.pix_fmt = input;
c.gop_size = fps * 2;
c.max_b_frames = 0;
c.sample_aspect_ratio = .{1, 1};
if input != .BGR0 { // swscale's conversion; NVENC picks and tags its own for BGRA
c.colorspace = .BT709;
c.color_primaries = .BT709;
c.color_trc = .BT709;
c.color_range = .MPEG;
}
if e.format.oformat.flags & AVFMT_GLOBALHEADER c.flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
options: *AVDictionary;
set :: (key: string, value: string) #expand { av_dict_set(*`options, temp_c_string(key), temp_c_string(value), 0); }
if name == {
case "h264_nvenc"; set("preset", "p4"); set("rc", "vbr"); set("cq", "23"); set("forced-idr", "1"); c.bit_rate = 0;
case "h264_amf"; set("rc", "cqp"); set("qp_i", "22"); set("qp_p", "24"); set("quality", "balanced");
case "h264_qsv"; set("global_quality", "23"); set("preset", "medium");
case "h264_mf"; set("rate_control", "quality"); set("quality", "70"); set("scenario", "display_remoting");
case "libx264"; set("preset", "veryfast"); set("crf", "23"); set("forced-idr", "1");
}
result := avcodec_open2(c, codec, *options);
av_dict_free(*options);
if result < 0 {
avcodec_free_context(*c);
return false, av_error("cannot open", result);
}
e.codec = c;
e.input = input;
e.name = name;
return true, "";
}
// BGRA as captured when the encoder takes it, else what swscale converts it to.
encoder_input_format :: (codec: *AVCodec) -> AVPixelFormat {
if !codec.pix_fmts return .NONE;
for wanted: AVPixelFormat.[.BGR0, .BGRA, .NV12, .YUV420P] {
format := codec.pix_fmts;
while format.* != .NONE {
if format.* == wanted return ifx wanted == .BGRA then .BGR0 else wanted;
format += 1;
}
}
return .NONE;
}
encoder_drain :: (e: *Encoder) -> ok: bool, error: string {
AVERROR_EAGAIN :: -11;
while true {
result := avcodec_receive_packet(e.codec, e.packet);
if result == AVERROR_EAGAIN || result == cast,no_check(s32) AVERROR_EOF return true, "";
if result < 0 return false, av_error(tprint("% failed", e.name), result);
av_packet_rescale_ts(e.packet, e.codec.time_base, e.stream.time_base);
e.packet.stream_index = e.stream.index;
e.bytes += e.packet.size;
result = av_interleaved_write_frame(e.format, e.packet); // takes the packet's data
if result < 0 return false, av_error("FFmpeg cannot write to the MP4", result);
}
return true, "";
}
encoder_free :: (e: *Encoder) {
if e.sws sws_freeContext(e.sws);
if e.frame av_frame_free(*e.frame);
if e.packet av_packet_free(*e.packet);
if e.codec avcodec_free_context(*e.codec);
if e.format {
if e.format.pb avio_closep(*e.format.pb);
avformat_free_context(e.format);
}
e.sws, e.format, e.stream = null, null, null;
}
#scope_module
// What FFmpeg says an error code is, after what failed (temp).
av_error :: (what: string, code: s32) -> string {
buffer: [256] u8;
av_strerror(code, buffer.data, buffer.count);
return tprint("% (%)", what, to_string(buffer.data));
}