- 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.
224 lines
8.9 KiB
Plaintext
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));
|
|
}
|