From 4d9ab6275a48552c8849ce91bbc3b6b00b30024d Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 13 Sep 2024 07:30:13 +0000 Subject: [PATCH 1/6] Fix occassional DTS overlap Fix an occassional DTS overlap by closing the filtergraph after each segment and re-creating it at the beginning of each segment, instead of attempting to persist the filtergraph in between segments. This overlap occurred mostly when flip-flopping segments between transcoders, or processing non-consecutive segments within a single transcoder. This was due to drift in adjusting input timestamps to match the fps filter's expectation of mostly consecutive timestamps while adjusting output timestamps to remove accumulated delay from the filter. There is roughly a 1% performance hit on my machine from re-creating the filtergraph. Because we are now resetting the filter after each segment, we can remove a good chunk of the special-cased timestamp handling code before and after the filtergraph since we no longer need to handle discontinuities between segments. However, we do need to keep some filter flushing logic in order to accommodate low-fps or low-frame content. This does change our outputs, usually by one fewer frame. Sometimes we seem to produce an *additional* frame - it is unclear why. However, as the test cases note, this actually clears up a numer of long-standing oddities around the expected frame count, so it should be seen as an improvement. --- It is important to note that while this fixes DTS overlap in a (rather unpredictable) general case, there is another overlap bug in one very specific case. These are the conditions for bug: 1. First and second segments of the stream are being processed. This could be the same transcoder or different ones. 2. The first segment starts at or near zero pts 3. mpegts is the output format 4. B-frames are being used What happens is we may see DTS < PTS for the very first frames in the very first segment, potentially starting with PTS = 0, DTS < 0. This is expected for B-frames. However, if mpegts is in use, it cannot take negative timestamps. To accompdate negative DTS, the muxer will set PTS = -DTS, DTS = 0 and delay (offset) the rest of the packets in the segment accordingly. Unfortunately, subsequent transcodes will not know about this delay! This typically leads to an overlap between the first and second segments (but segments after that would be fine). The normal way to fix this would be to add a constant delay to all segments - ffmpeg adds 1.4s to mpegts by default. However, introducing a delay right now feels a little odd since we don't really offer any other knobs to control the timestamp (re-transcodes would accumulate the delay) and there is some concern about falling out of sync with the source segment since we have historically tried to make timestamps follow the source as closely as possible. So we're leaving this particular bug as-is for now. There is some commented-out code that adds this delay in case we feel that we would need it in the future. Note that FFmpeg CLI also has the exact same problem when the muxer delay is removed, so this is not a LPMS-specific issue. This is exercised in the test cases. Example of non-monotonic DTS after encoding and after muxing: Segment.Frame | Encoder DTS | Encoder PTS | Muxer DTS | Muxer PTS --------------|-------------|-------------|-----------|----------- 1.1 | -20 | 0 | 0 | 20 1.2 | -10 | 10 | 10 | 30 1.3 | 0 | 20 | *20* | 40 1.4 | 10 | 30 | *30* | 50 2.1 | 20 | 40 | *20* | 40 2.2 | 30 | 50 | *30* | 50 2.3 | 40 | 60 | 40 | 60 --- ffmpeg/api_test.go | 181 +++++++++++++++++++++++++++++++++++------- ffmpeg/encoder.c | 34 ++++++-- ffmpeg/ffmpeg_test.go | 54 ++++++++----- ffmpeg/filter.c | 37 +++------ ffmpeg/filter.h | 15 +--- ffmpeg/nvidia_test.go | 30 ++++--- 6 files changed, 241 insertions(+), 110 deletions(-) diff --git a/ffmpeg/api_test.go b/ffmpeg/api_test.go index d5c4946ab0..ee7f75beb5 100755 --- a/ffmpeg/api_test.go +++ b/ffmpeg/api_test.go @@ -51,7 +51,7 @@ func TestAPI_SkippedSegment(t *testing.T) { if res.Decoded.Frames != 120 { t.Error("Did not get decoded frames", res.Decoded.Frames) } - if res.Encoded[1].Frames != 245 { + if res.Encoded[1].Frames != 246 { t.Error("Did not get encoded frames ", res.Encoded[1].Frames) } } @@ -68,7 +68,7 @@ func TestAPI_SkippedSegment(t *testing.T) { # sanity check ffmpeg frame count against ours ffprobe -count_frames -show_streams -select_streams v ffmpeg_sw_$1.ts | grep nb_read_frames=246 - ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=245 + ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=246 # check image quality # TODO really should have frame counts match for ssim @@ -224,18 +224,14 @@ func countEncodedFrames(t *testing.T, accel Acceleration) { if err != nil { t.Error(err) } - expectedFrames := 60 - if i == 1 || i == 3 { - expectedFrames = 61 // TODO figure out why this is! - } - if res.Encoded[0].Frames != expectedFrames { - t.Error(in.Fname, " Mismatched frame count: expected ", expectedFrames, " got ", res.Encoded[0].Frames) + if res.Encoded[0].Frames != 60 { + t.Error(in.Fname, " Mismatched frame count: expected 60 got ", res.Encoded[0].Frames) } if res.Encoded[1].Frames != 120 { t.Error(in.Fname, " Mismatched frame count: expected 120 got ", res.Encoded[1].Frames) } - if res.Encoded[2].Frames != 239 { - t.Error(in.Fname, " Mismatched frame count: expected 239 got ", res.Encoded[2].Frames) + if res.Encoded[2].Frames != 240 { + t.Error(in.Fname, " Mismatched frame count: expected 240 got ", res.Encoded[2].Frames) } if res.Encoded[3].Frames != 120 { t.Error(in.Fname, " Mismatched frame count: expected 120 got ", res.Encoded[3].Frames) @@ -257,33 +253,33 @@ func countEncodedFrames(t *testing.T, accel Acceleration) { pts=129000 pts=129750 pts=130500 -pts=306000 pts=306750 pts=307500 +pts=308250 ==> out_120fps_1.ts.pts <== pts=309000 pts=309750 pts=310500 -pts=486000 pts=486750 pts=487500 +pts=488250 ==> out_120fps_2.ts.pts <== pts=489000 pts=489750 pts=490500 -pts=666000 pts=666750 pts=667500 +pts=668250 ==> out_120fps_3.ts.pts <== pts=669000 pts=669750 pts=670500 -pts=846000 pts=846750 pts=847500 +pts=848250 ==> out_30fps_0.ts.pts <== pts=129000 @@ -297,9 +293,9 @@ pts=306000 pts=309000 pts=312000 pts=315000 +pts=480000 pts=483000 pts=486000 -pts=489000 ==> out_30fps_2.ts.pts <== pts=489000 @@ -313,9 +309,9 @@ pts=666000 pts=669000 pts=672000 pts=675000 +pts=840000 pts=843000 pts=846000 -pts=849000 ==> out_60fps_0.ts.pts <== pts=129000 @@ -463,8 +459,8 @@ func TestTranscoder_API_AlternatingTimestamps(t *testing.T) { # sanity check ffmpeg frame count against ours ffprobe -count_frames -show_streams -select_streams v ffmpeg_sw_$1.ts | grep nb_read_frames=246 - ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=245 - ffprobe -count_frames -show_streams -select_streams v sw_audio_encode_$1.ts | grep nb_read_frames=245 + ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=246 + ffprobe -count_frames -show_streams -select_streams v sw_audio_encode_$1.ts | grep nb_read_frames=246 # check image quality # TODO frame count should really match for ssim @@ -476,12 +472,146 @@ func TestTranscoder_API_AlternatingTimestamps(t *testing.T) { # Really should check relevant audio as well... } - - # re-enable for seg 0 and 1 when alternating timestamps can be handled check 0 check 1 check 2 check 3 + ` + run(cmd) + +} + +func TestTranscoder_API_DTSOverlap(t *testing.T) { + dtsOverlap(t, Software) +} + +func dtsOverlap(t *testing.T, accel Acceleration) { + // Non-monotonic DTS timestamps are a major problem. + // We have one such case here when: + // 1. first segment pts starts near zero + // 2. B-frames are in use + // 3. mpegts is the output format + // + // the transcoder can produce DTS < 0, PTS = 0 which gets + // offset to DTS = 0, PTS = -DTS in the mpegts muxer + // + // However, transcodes for other segments will not be aware + // of this delay, leading to overlap between the first and + // second segments. + // + // This is not a LPMS specific issue but rather one in the + // employment of mpegts (always add an offset!); ffmpeg has + // the exact same issue. + + // This test case codifies this behavior for now as a sign + // that we are aware of it, and if it ever changes somehow, + // this should fail and let us know. + + run, dir := setupTest(t) + defer os.RemoveAll(dir) + + err := RTMPToHLS("../transcoder/test.ts", dir+"/out.m3u8", dir+"/out_%d.ts", "2", 0) + if err != nil { + t.Error(err) + } + + profile := P144p30fps16x9 + profile.Framerate = 15 + profile.Profile = ProfileH264Main + // and check no bframes case (which is ok) + profileNoB := profile + profileNoB.Profile = ProfileH264ConstrainedHigh + tc := NewTranscoder() + defer tc.StopTranscoder() + idx := []int{1, 0} + for _, i := range idx { + in := &TranscodeOptionsIn{Fname: fmt.Sprintf("%s/out_%d.ts", dir, i)} + out := []TranscodeOptions{{ + Oname: fmt.Sprintf("%s/bf_%d.ts", dir, i), + Profile: profile, + AudioEncoder: ComponentOptions{Name: "copy"}, + Accel: accel, + }, { + Oname: fmt.Sprintf("%s/nobf_%d.ts", dir, i), + Profile: profileNoB, + AudioEncoder: ComponentOptions{Name: "copy"}, + Accel: accel, + }} + res, err := tc.Transcode(in, out) + if err != nil { + t.Error(err) + } + if res != nil { + if res.Decoded.Frames != 120 { + t.Error("Did not get decoded frames", res.Decoded.Frames) + } + if res.Encoded[0].Frames != 30 { + t.Error("Mismatched frame count for hw/nv") + } + } + } + + cmd := ` + + # ffmpeg has the exact same problem so let's demonstrate that too + ffmpeg -loglevel warning -i out_0.ts -c:a copy \ + -vf fps=15,scale=w=256:h=144 -c:v libx264 -muxdelay 0 -muxpreload 0 -copyts \ + ffmpeg_sw_0.ts + ffmpeg -loglevel warning -i out_1.ts -c:a copy \ + -vf fps=15,scale=w=256:h=144 -c:v libx264 -muxdelay 0 -muxpreload 0 -copyts \ + ffmpeg_sw_1.ts + + + # Ensure timestamps are monotonic by checking deltas + # do this for the low fps rendition since those are more + # likely to have issues while downsampling fps + function calc_delta { + cat $1_0.ts $1_1.ts > $1_concat.ts + mapfile -t dts_times < <(ffprobe -hide_banner -select_streams v -of csv=p=0 -show_entries packet=dts_time $1_concat.ts | awk -F',' '{print $1}') + # Loop through the array and calculate the delta + for ((i = 1; i < ${#dts_times[@]}; i++)); do + delta=$(awk "BEGIN {printf \"%.6f\", ${dts_times[$i]} - ${dts_times[$i-1]}}" | sed -e 's/^0\./\./' -e 's/^-0\./-\./') + echo "$delta" >> $1_concat.delta + done + sort $1_concat.delta | uniq -c | sed 's/^ *//g' > $2 + } + + calc_delta bf deltas.out + calc_delta nobf deltas_nobf.out + calc_delta ffmpeg_sw ffmpeg_deltas.out + ` + + if accel == Nvidia { + cmd = cmd + ` + cat <<-EOF > expected_deltas.out + 1 -.133333 + 20 .066666 + 38 .066667 + EOF + ` + } else { + // for sw transcode, ffmpeg and lpms are exactly the same + cmd = cmd + ` + diff -u deltas.out ffmpeg_deltas.out + + cat <<-EOF > expected_deltas.out + 1 -.066666 + 20 .066666 + 38 .066667 + EOF + ` + } + + cmd = cmd + ` + diff -u expected_deltas.out deltas.out + + # no b-frames case + cat <<-EOF > expected_deltas_nobf.out + 20 .066666 + 39 .066667 + EOF + diff -u expected_deltas_nobf.out deltas_nobf.out + ` run(cmd) } @@ -795,11 +925,7 @@ func consecutiveMP4s(t *testing.T, accel Acceleration) { t.Error("Unexpected error ", err) continue } - expectedFrames := 60 - if i == 1 || i == 3 { - expectedFrames = 61 // TODO figure out why this is! - } - if res.Decoded.Frames != 120 || res.Encoded[0].Frames != expectedFrames { + if res.Decoded.Frames != 120 || res.Encoded[0].Frames != 60 { t.Error("Unexpected results ", i, inExt, outExt, res) } } @@ -1119,9 +1245,8 @@ func setGops(t *testing.T, accel Acceleration) { # intra checks with fixed fps. # sanity check number of packets vs keyframes - # TODO look into why lpms generates 91 frames instead of 100 - ffprobe -loglevel warning lpms_intra_10fps.ts -select_streams v -show_packets | grep flags= | wc -l | grep 91 - ffprobe -loglevel warning lpms_intra_10fps.ts -select_streams v -show_packets | grep flags=K | wc -l | grep 91 + ffprobe -loglevel warning lpms_intra_10fps.ts -select_streams v -show_packets | grep flags= | wc -l | grep 100 + ffprobe -loglevel warning lpms_intra_10fps.ts -select_streams v -show_packets | grep flags=K | wc -l | grep 100 ` run(cmd) diff --git a/ffmpeg/encoder.c b/ffmpeg/encoder.c index 3d049e0cf5..bce9c4bba2 100755 --- a/ffmpeg/encoder.c +++ b/ffmpeg/encoder.c @@ -160,11 +160,9 @@ void close_output(struct output_ctx *octx) } if (octx->vc && octx->hw_type == AV_HWDEVICE_TYPE_NONE) avcodec_free_context(&octx->vc); if (octx->ac) avcodec_free_context(&octx->ac); + free_filter(&octx->vf); octx->af.flushed = octx->vf.flushed = 0; octx->af.flushing = octx->vf.flushing = 0; - octx->vf.pts_diff = INT64_MIN; - octx->vf.prev_frame_pts = 0; - octx->vf.segments_complete++; } void free_output(struct output_ctx *octx) @@ -473,6 +471,19 @@ int mux(AVPacket *pkt, AVRational tb, struct output_ctx *octx, AVStream *ost) av_packet_rescale_ts(pkt, tb, ost->time_base); } + /* Enable this if it seems we have issues + with the first and second segments overlapping due to bframes + See TestTranscoder_API_DTSOverlap + + int delay = av_rescale_q(10, (AVRational){1, 1}, ost->time_base); + if (pkt->dts != AV_NOPTS_VALUE) { + pkt->dts += delay; + } + if (pkt->pts != AV_NOPTS_VALUE) { + pkt->pts += delay; + } + */ + // drop any preroll audio. may need to drop multiple packets for multichannel // XXX this breaks if preroll isn't exactly one AVPacket or drop_ts == 0 // hasn't been a problem in practice (so far) @@ -544,13 +555,26 @@ int process_out(struct input_ctx *ictx, struct output_ctx *octx, AVCodecContext if (!encoder) LPMS_ERR(proc_cleanup, "Trying to transmux; not supported") + int is_video = (AVMEDIA_TYPE_VIDEO == ost->codecpar->codec_type); + int is_audio = (AVMEDIA_TYPE_AUDIO == ost->codecpar->codec_type); + + if (is_video && filter && !filter->active && inf) { + ret = init_video_filters(ictx, octx, inf); + if (ret < 0) LPMS_ERR(proc_cleanup, "Unable to initialize video filter"); + } + if (!filter || !filter->active) { + // Don't call encode if nothing has been sent to CUDA yet (via filter + // lazy init) because it may cause odd interactions with flushing + if (is_video && !inf && + octx->hw_type > AV_HWDEVICE_TYPE_NONE && + AV_HWDEVICE_TYPE_MEDIACODEC != octx->hw_type) { + return AVERROR_EOF; + } // No filter in between decoder and encoder, so use input frame directly return encode(encoder, inf, octx, ost); } - int is_video = (AVMEDIA_TYPE_VIDEO == ost->codecpar->codec_type); - int is_audio = (AVMEDIA_TYPE_AUDIO == ost->codecpar->codec_type); ret = filtergraph_write(inf, ictx, octx, filter, is_video); if (ret < 0) goto proc_cleanup; diff --git a/ffmpeg/ffmpeg_test.go b/ffmpeg/ffmpeg_test.go index f3896a9226..28a570e97e 100644 --- a/ffmpeg/ffmpeg_test.go +++ b/ffmpeg/ffmpeg_test.go @@ -494,16 +494,17 @@ func TestTranscoder_Statistics_Encoded(t *testing.T) { t.Error("Mismatched pixel counts") } // Since this is a 1-second input we should ideally have count of frames - if r.Frames != int(out[i].Profile.Framerate+1) { - + if r.Frames == int(out[i].Profile.Framerate) { + // cool all good + } else { // Some "special" cases (already have test cases covering these) if p144p60fps == out[i].Profile { if r.Frames != int(out[i].Profile.Framerate)+1 { t.Error("Mismatched frame counts for 60fps; expected 61 frames but got ", r.Frames) } } else if podd123fps == out[i].Profile { - if r.Frames != 124 { - t.Error("Mismatched frame counts for 123fps; expected 124 frames but got ", r.Frames) + if r.Frames != 125 { + t.Error("Mismatched frame counts for 123fps; expected 125 frames but got ", r.Frames) } } else { t.Error("Mismatched frame counts ", r.Frames, out[i].Profile.Framerate) @@ -560,7 +561,7 @@ func TestTranscoder_StatisticsAspectRatio(t *testing.T) { t.Error(err) } r := res.Encoded[0] - if r.Frames != int(pAdj.Framerate+1) || r.Pixels != int64(r.Frames*146*82) { + if r.Frames != int(pAdj.Framerate) || r.Pixels != int64(r.Frames*146*82) { t.Error(fmt.Errorf("Results did not match: %v ", r)) } } @@ -852,7 +853,7 @@ func TestTranscoder_StreamCopy(t *testing.T) { if err != nil { t.Error(err) } - if res.Decoded.Frames != 60 || res.Encoded[0].Frames != 31 || + if res.Decoded.Frames != 60 || res.Encoded[0].Frames != 30 || res.Encoded[1].Frames != 0 { t.Error("Unexpected frame counts from stream copy") t.Error(res) @@ -996,7 +997,7 @@ func TestTranscoder_Drop(t *testing.T) { if err != nil { t.Error(err) } - if res.Decoded.Frames != 60 || res.Encoded[0].Frames != 31 { + if res.Decoded.Frames != 60 || res.Encoded[0].Frames != 30 { t.Error("Unexpected count of decoded frames ", res.Decoded.Frames, res.Decoded.Pixels) } @@ -1028,7 +1029,7 @@ func TestTranscoder_Drop(t *testing.T) { if err != nil { t.Error(err) } - if res.Decoded.Frames != 31 || res.Encoded[0].Frames != 31 { + if res.Decoded.Frames != 30 || res.Encoded[0].Frames != 30 { t.Error("Unexpected encoded/decoded frame counts ", res.Decoded.Frames, res.Encoded[0].Frames) } in.Fname = dir + "/novideo.ts" @@ -1224,7 +1225,7 @@ func TestTranscoder_RepeatedTranscodes(t *testing.T) { in = &TranscodeOptionsIn{Fname: dir + "/test-short-with-audio.ts"} out = []TranscodeOptions{{Oname: dir + "/audio-0.ts", Profile: P144p30fps16x9}} res, err = Transcode3(in, out) - if err != nil || res.Decoded.Frames != 60 || res.Encoded[0].Frames != 31 { + if err != nil || res.Decoded.Frames != 60 || res.Encoded[0].Frames != 30 { t.Error("Unexpected preconditions ", err, res) } frames = res.Encoded[0].Frames @@ -1399,7 +1400,7 @@ nb_read_frames=%d b.Flush() // Run a ffmpeg command that attempts to match the given encode settings - run(fmt.Sprintf(`ffmpeg -loglevel warning -hide_banner -i %s -vsync passthrough -c:a aac -ar 44100 -ac 2 -c:v libx264 -vf fps=%d/1:eof_action=pass,scale=%dx%d -copyts -muxdelay 0 -y ffmpeg.ts`, in.Fname, out.Profile.Framerate, w, h)) + run(fmt.Sprintf(`ffmpeg -loglevel warning -hide_banner -i %s -vsync passthrough -c:a aac -ar 44100 -ac 2 -c:v libx264 -vf fps=%d/1,scale=%dx%d -copyts -muxdelay 0 -y ffmpeg.ts`, in.Fname, out.Profile.Framerate, w, h)) // Gather some ffprobe stats on the output of the above ffmpeg command run(`ffprobe -loglevel warning -hide_banner -count_frames -select_streams v -show_streams 2>&1 ffmpeg.ts | grep '^width=\|^height=\|nb_read_frames=' > ffmpeg.stats`) @@ -1439,7 +1440,7 @@ nb_read_frames=%d if err != nil { t.Error(err) } - if res.Encoded[0].Frames != 31 { + if res.Encoded[0].Frames != 30 { t.Error("Did not get expected frame count ", res.Encoded[0].Frames) } checkStatsFile(in, &out[0], res) @@ -1465,10 +1466,10 @@ nb_read_frames=%d if err != nil { t.Error(err) } - if res.Encoded[0].Frames != 124 { // TODO Find out why this isn't 126 (ffmpeg) + if res.Encoded[0].Frames != 125 { // TODO Find out why this isn't 126 (ffmpeg) t.Error("Did not get expected frame count ", res.Encoded[0].Frames) } - // checkStatsFile(in, &out[0], res) // TODO framecounts don't match ffmpeg + checkStatsFile(in, &out[0], res) } func TestTranscoder_PassthroughFPS(t *testing.T) { @@ -2065,9 +2066,10 @@ PTS_EOF // check output cmd = ` # reproduce expected lpms output using ffmpeg - ffmpeg -debug_ts -loglevel trace -i in.ts -vf 'scale=136x240,fps=30/1:eof_action=pass' -c:v libx264 -copyts -muxdelay 0 out-ffmpeg.ts + ffmpeg -debug_ts -loglevel trace -i in.ts -vf 'scale=136x240,fps=30/1' -c:v libx264 -copyts -muxdelay 0 out-ffmpeg.ts - ffprobe -show_packets out-ffmpeg.ts | grep dts= > ffmpeg-dts.out + # ffmpeg produces one more packet than lpms in this case so just trim that out + ffprobe -show_packets out-ffmpeg.ts | grep dts= | head -n -1 > ffmpeg-dts.out ffprobe -show_packets out0in.ts | grep dts= > lpms-dts.out diff -u lpms-dts.out ffmpeg-dts.out @@ -2223,7 +2225,7 @@ func runRotationTests(t *testing.T, accel Acceleration) { require.NoError(t, err) assert.Equal(t, 360, res.Decoded.Frames) - assert.Equal(t, 181, res.Encoded[0].Frames) // should be 180 ... ts rounding ? + assert.Equal(t, 180, res.Encoded[0].Frames) assert.Equal(t, 360, res.Encoded[1].Frames) // TODO test rollover of gop interval during flush @@ -2256,7 +2258,7 @@ func runRotationTests(t *testing.T, accel Acceleration) { cat <<-EOF2 > expected-30fps.dims 60 256,144 60 146,260 - 61 256,144 + 60 256,144 EOF2 diff -u expected.dims out.dims @@ -2341,7 +2343,7 @@ func runRotationTests(t *testing.T, accel Acceleration) { require.NoError(t, err) assert.Equal(t, 240, res.Decoded.Frames) - assert.Equal(t, 121, res.Encoded[0].Frames) // should be 120 ... ts rounding ? + assert.Equal(t, 120, res.Encoded[0].Frames) assert.Equal(t, 240, res.Encoded[1].Frames) cmd = ` @@ -2358,7 +2360,7 @@ func runRotationTests(t *testing.T, accel Acceleration) { cat <<-EOF2 > single-expected-30fps.dims 60 256,144 - 61 146,260 + 60 146,260 EOF2 diff -u single-expected.dims single-out.dims @@ -2434,10 +2436,20 @@ func TestTranscoder_PNGDemuxerOpts(t *testing.T) { // so test those run, dir := setupTest(t) defer os.RemoveAll(dir) + // generate 3 png frames cmd := ` ffmpeg -i $1/../transcoder/test.ts -an -frames:v 3 test-%d.png + + # Run an ffmpeg CLI equivalent for this PNG demuxer path. + ffmpeg -framerate 1/3 -f image2 -i test-%d.png -an \ + -vf "fps=30/1,scale=256:144" -c:v libx264 -pix_fmt yuv420p \ + -f mpegts -y ffmpeg-equivalent.ts + + # Check packet count from ffprobe. + ffprobe -v warning -count_packets -show_streams -select_streams v ffmpeg-equivalent.ts | grep nb_read_packets=270 ` run(cmd) + // 1 frame every 3 seconds, with 3 frames == 9 seconds res, err := Transcode3(&TranscodeOptionsIn{ Fname: dir + "/test-%d.png", Profile: VideoProfile{ @@ -2450,7 +2462,7 @@ func TestTranscoder_PNGDemuxerOpts(t *testing.T) { }}) assert.Nil(t, err) assert.Equal(t, 3, res.Decoded.Frames) - assert.Equal(t, 180, res.Encoded[0].Frames) + assert.Equal(t, 270, res.Encoded[0].Frames) // 9 seconds * 30fps == 270 } func TestTranscode_DurationLimit(t *testing.T) { @@ -2684,7 +2696,7 @@ func TestTranscoder_LargeOutputs(t *testing.T) { frame,video,25994.500000,25994.500000,B frame,video,25994.533333,25994.533333,B frame,video,25994.566667,25994.566667,B - frame,video,25994.600000,25994.600000,P + frame,video,25994.600000,25994.600000,P, frame,video,25994.666667,25994.666667,P, frame,video,25994.700000,25994.700000,B, frame,video,25994.733333,25994.733333,B, diff --git a/ffmpeg/filter.c b/ffmpeg/filter.c index bb0cf93f4a..7ac495130d 100644 --- a/ffmpeg/filter.c +++ b/ffmpeg/filter.c @@ -70,7 +70,6 @@ int init_video_filters(struct input_ctx *ictx, struct output_ctx *octx, AVFrame if (vf->graph == NULL) { vf->graph = avfilter_graph_alloc(); } - vf->pts_diff = INT64_MIN; if (!outputs || !inputs || !vf->graph) { ret = AVERROR(ENOMEM); LPMS_ERR(vf_init_cleanup, "Unable to allocate filters"); @@ -125,6 +124,7 @@ int init_video_filters(struct input_ctx *ictx, struct output_ctx *octx, AVFrame if (!vf->frame) LPMS_ERR(vf_init_cleanup, "Unable to allocate video frame"); vf->active = 1; + vf->closed = 0; vf_init_cleanup: avfilter_inout_free(&inputs); @@ -223,7 +223,6 @@ int init_signature_filters(struct output_ctx *octx, AVFrame *inf) outputs = avfilter_inout_alloc(); inputs = avfilter_inout_alloc(); sf->graph = avfilter_graph_alloc(); - sf->pts_diff = INT64_MIN; if (!outputs || !inputs || !sf->graph) { ret = AVERROR(ENOMEM); LPMS_ERR(sf_init_cleanup, "Unable to allocate filters"); @@ -284,6 +283,7 @@ int init_signature_filters(struct output_ctx *octx, AVFrame *inf) int filtergraph_write(AVFrame *inf, struct input_ctx *ictx, struct output_ctx *octx, struct filter_ctx *filter, int is_video) { + if (filter->closed) return 0; int ret = 0; // We have to reset the filter because we initially set the filter // before the decoder is fully ready, and the decoder may change HW params @@ -335,21 +335,15 @@ int filtergraph_write(AVFrame *inf, struct input_ctx *ictx, struct output_ctx *o AVStream *vst = ictx->ic->streams[ictx->vi]; if (inf) { // Non-Flush Frame inf->opaque = (void *) inf->pts; // Store original PTS for calc later - if (is_video && octx->fps.den) { - // Custom PTS set when FPS filter is used - int64_t ts_step = inf->pts - filter->prev_frame_pts; - if (filter->segments_complete && !filter->prev_frame_pts) { - // We are on the first frame of the second (or later) segment - // So in this case just increment the pts by 1/fps - ts_step = av_rescale_q_rnd(1, av_inv_q(octx->fps), vst->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX); - } - filter->custom_pts += ts_step; - filter->prev_frame_pts = inf->pts; - } else { - // FPS Passthrough or Audio case - filter->custom_pts = inf->pts; - } + filter->custom_pts = inf->pts; } else if (!filter->flushed) { // Flush Frame + // close filter right away if we already have some frames + if (octx->res->frames) { + filter->closed = 1; + return av_buffersrc_write_frame(filter->src_ctx, NULL); + } + // we don't have frames yet so flush the filter + // needed for extremely short or low-fps content int64_t ts_step; inf = (is_video) ? ictx->last_frame_v : ictx->last_frame_a; inf->opaque = (void *) (INT64_MIN); // Store INT64_MIN as pts for flush frames @@ -392,17 +386,6 @@ int filtergraph_read(struct input_ctx *ictx, struct output_ctx *octx, struct fil // don't set flushed flag in case this is a flush from a previous segment if (filter->flushing) filter->flushed = 1; ret = lpms_ERR_FILTER_FLUSHED; - } else if (frame && is_video && octx->fps.den) { - // TODO why limit to fps filter? what about non-fps filtergraphs, eg scale? - // We set custom PTS as an input of the filtergraph so we need to - // re-calculate our output PTS before passing it on to the encoder - if (filter->pts_diff == INT64_MIN) { - int64_t pts = (int64_t)frame->opaque; // original input PTS - pts = av_rescale_q_rnd(pts, ictx->ic->streams[ictx->vi]->time_base, av_buffersink_get_time_base(filter->sink_ctx), AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX); - // difference between rescaled input PTS and the segment's first frame PTS of the filtergraph output - filter->pts_diff = pts - frame->pts; - } - frame->pts += filter->pts_diff; // Re-calculate by adding back this segment's difference calculated at start } fg_read_cleanup: return ret; diff --git a/ffmpeg/filter.h b/ffmpeg/filter.h index 026a8ada8b..b2f4b0fc48 100755 --- a/ffmpeg/filter.h +++ b/ffmpeg/filter.h @@ -22,19 +22,6 @@ struct filter_ctx { // uniformly and monotonically increasing. int64_t custom_pts; - // Previous PTS to be used to manually calculate duration for custom_pts - int64_t prev_frame_pts; - - // Count of complete segments that have been processed by this filtergraph - int segments_complete; - - // We need to update the post-filtergraph PTS before sending the frame for - // encoding because we modified the input PTS. - // We do this by calculating the difference between our custom PTS and actual - // PTS for the first-frame of every segment, and then applying this diff to - // every subsequent frame in the segment. - int64_t pts_diff; - // When draining the filtergraph, we inject fake frames. // These frames have monotonically increasing timestamps at the same interval // as a normal stream of frames. The custom_pts is set to more than usual jump @@ -43,6 +30,8 @@ struct filter_ctx { // We mark this boolean as flushed when done flushing. int flushed; int flushing; + + int closed; }; struct output_ctx { diff --git a/ffmpeg/nvidia_test.go b/ffmpeg/nvidia_test.go index d53826499d..0a7df8d2ea 100755 --- a/ffmpeg/nvidia_test.go +++ b/ffmpeg/nvidia_test.go @@ -423,12 +423,10 @@ func TestNvidia_DrainFilters(t *testing.T) { # sanity check with ffmpeg itself ffmpeg -loglevel warning -i test.ts -c:a copy -c:v libx264 -vf fps=100 -vsync 0 ffmpeg-out.ts - ffprobe -loglevel warning -show_streams -select_streams v -count_frames ffmpeg-out.ts > ffmpeg.out - ffprobe -loglevel warning -show_streams -select_streams v -count_frames out.ts > probe.out + ffprobe -loglevel warning -select_streams v -count_frames -show_entries stream=nb_read_frames,time_base,duration,r_frame_rate,avg_frame_rate,start_pts,start_time,duration_ts,duration ffmpeg-out.ts > ffmpeg.out + ffprobe -loglevel warning -select_streams v -count_frames -show_entries stream=nb_read_frames,time_base,duration,r_frame_rate,avg_frame_rate,start_pts,start_time,duration_ts,duration out.ts > probe.out - # These used to be same, but aren't since we've diverged the flushing and PTS handling from ffmpeg - grep nb_read_frames=101 probe.out - grep duration=1.0100 probe.out + diff -u ffmpeg.out probe.out grep nb_read_frames=102 ffmpeg.out grep duration=1.0200 ffmpeg.out ` @@ -559,9 +557,9 @@ func TestNvidia_API_MixedOutput(t *testing.T) { # sanity check ffmpeg frame count against ours ffprobe -count_frames -show_streams -select_streams v ffmpeg_nv_$1.ts | grep nb_read_frames=246 - ffprobe -count_frames -show_streams -select_streams v nv_$1.ts | grep nb_read_frames=245 - ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=245 - ffprobe -count_frames -show_streams -select_streams v nv_audio_encode_$1.ts | grep nb_read_frames=245 + ffprobe -count_frames -show_streams -select_streams v nv_$1.ts | grep nb_read_frames=246 + ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=246 + ffprobe -count_frames -show_streams -select_streams v nv_audio_encode_$1.ts | grep nb_read_frames=246 # check image quality ffmpeg -loglevel warning -i nv_$1.ts -i ffmpeg_nv_$1.ts \ @@ -601,6 +599,7 @@ func TestNvidia_API_AlternatingTimestamps(t *testing.T) { profile := P144p30fps16x9 profile.Framerate = 123 tc := NewTranscoder() + defer tc.StopTranscoder() idx := []int{1, 0, 3, 2} for _, i := range idx { // TODO this breaks with nvidia acceleration on the input! @@ -624,9 +623,6 @@ func TestNvidia_API_AlternatingTimestamps(t *testing.T) { Profile: profile, }} res, err := tc.Transcode(in, out) - if (i == 1 || i == 3) && err != nil { - t.Error(err) - } if err != nil { t.Error(err) } @@ -651,9 +647,9 @@ func TestNvidia_API_AlternatingTimestamps(t *testing.T) { # sanity check ffmpeg frame count against ours ffprobe -count_frames -show_streams -select_streams v ffmpeg_nv_$1.ts | grep nb_read_frames=246 - ffprobe -count_frames -show_streams -select_streams v nv_$1.ts | grep nb_read_frames=245 - ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=245 - ffprobe -count_frames -show_streams -select_streams v nv_audio_encode_$1.ts | grep nb_read_frames=245 + ffprobe -count_frames -show_streams -select_streams v nv_$1.ts | grep nb_read_frames=246 + ffprobe -count_frames -show_streams -select_streams v sw_$1.ts | grep nb_read_frames=246 + ffprobe -count_frames -show_streams -select_streams v nv_audio_encode_$1.ts | grep nb_read_frames=246 # check image quality ffmpeg -loglevel warning -i nv_$1.ts -i ffmpeg_nv_$1.ts \ @@ -674,11 +670,13 @@ func TestNvidia_API_AlternatingTimestamps(t *testing.T) { check 1 check 2 check 3 - ` + ` run(cmd) - tc.StopTranscoder() } +func TestNvidia_DTSOverlap(t *testing.T) { + dtsOverlap(t, Nvidia) +} func TestNvidia_ShortSegments(t *testing.T) { shortSegments(t, Nvidia, 1) shortSegments(t, Nvidia, 2) From 6eb7e5ef874e2a0d97ccc7db3a5f64630a4e9f6c Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 5 Mar 2026 22:58:55 -0800 Subject: [PATCH 2/6] Handle AV_NOPTS_VALUE inputs --- ffmpeg/decoder.c | 33 +++++++++++++++++++++++++++++++++ ffmpeg/decoder.h | 2 ++ ffmpeg/ffmpeg_test.go | 34 ++++++++++++++++++---------------- ffmpeg/transcoder.c | 1 + 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/ffmpeg/decoder.c b/ffmpeg/decoder.c index f8101de2d4..29823aee02 100755 --- a/ffmpeg/decoder.c +++ b/ffmpeg/decoder.c @@ -11,11 +11,43 @@ static int lpms_send_packet(struct input_ctx *ictx, AVCodecContext *dec, AVPacke return ret; } +static int64_t decoded_video_pts_step(struct input_ctx *ictx, AVFrame *frame) +{ + if (frame && frame->duration > 0) return frame->duration; + AVStream *vst = (ictx && ictx->ic && ictx->vi >= 0) ? ictx->ic->streams[ictx->vi] : NULL; + if (vst && vst->r_frame_rate.num > 0 && vst->r_frame_rate.den > 0) { + int64_t step = av_rescale_q(1, av_inv_q(vst->r_frame_rate), vst->time_base); + if (step > 0) return step; + } + return 1; +} + +// Fix malformed decode timestamps (missing/regressive PTS) so downstream stages +// receive a stable, non-AV_NOPTS_VALUE video timeline. +static void fix_video_pts(struct input_ctx *ictx, AVFrame *frame) +{ + int64_t pts = frame->pts; + int synthesized = 0; + if (pts == AV_NOPTS_VALUE) pts = frame->best_effort_timestamp; + if (pts == AV_NOPTS_VALUE) { + pts = decoded_video_pts_step(ictx, frame); + if (ictx->last_video_pts != AV_NOPTS_VALUE) pts += ictx->last_video_pts; + synthesized = 1; + } + if (ictx->last_video_pts != AV_NOPTS_VALUE && pts <= ictx->last_video_pts) { + int64_t step = synthesized ? decoded_video_pts_step(ictx, frame) : 1; + pts = ictx->last_video_pts + step; + } + frame->pts = pts; + ictx->last_video_pts = pts; +} + static int lpms_receive_frame(struct input_ctx *ictx, AVCodecContext *dec, AVFrame *frame) { int ret = avcodec_receive_frame(dec, frame); if (dec != ictx->vc) return ret; if (!ret && frame && !is_flush_frame(frame)) { + fix_video_pts(ictx, frame); ictx->pkt_diff--; // decrease buffer count for non-sentinel video frames if (ictx->flushing) ictx->sentinel_count = 0; } @@ -328,6 +360,7 @@ int open_input(input_params *params, struct input_ctx *ctx) if (!ctx->last_frame_v) LPMS_ERR(open_input_err, "Unable to alloc last_frame_v"); ctx->last_frame_a = av_frame_alloc(); if (!ctx->last_frame_a) LPMS_ERR(open_input_err, "Unable to alloc last_frame_a"); + ctx->last_video_pts = AV_NOPTS_VALUE; return 0; diff --git a/ffmpeg/decoder.h b/ffmpeg/decoder.h index ab82948aab..7ffde76882 100755 --- a/ffmpeg/decoder.h +++ b/ffmpeg/decoder.h @@ -36,6 +36,8 @@ struct input_ctx { #define SENTINEL_MAX 8 uint16_t sentinel_count; + int64_t last_video_pts; // Resets after each segment + // Packet held while decoder is blocked and needs to drain AVPacket *blocked_pkt; diff --git a/ffmpeg/ffmpeg_test.go b/ffmpeg/ffmpeg_test.go index 28a570e97e..d0224a41aa 100644 --- a/ffmpeg/ffmpeg_test.go +++ b/ffmpeg/ffmpeg_test.go @@ -2543,8 +2543,8 @@ func TestTranscoder_LargeOutputs(t *testing.T) { close(closeCh) assert.Nil(err) assert.Equal(120, res.Decoded.Frames) - assert.Equal(116, res.Encoded[0].Frames) // ffmpeg probably drops missing timestamp frames - assert.Equal(56, res.Encoded[1].Frames) + assert.Equal(120, res.Encoded[0].Frames) // passthrough + assert.Equal(60, res.Encoded[1].Frames) // 30fps, 2 second input cmd := ` # check input properties to ensure they still have the weird timestamps ffprobe -of csv -hide_banner -show_entries frame=pts_time,pkt_dts_time,media_type,pict_type $1/../data/missing-dts.ts 2>&1 | grep video > input.out @@ -2675,8 +2675,6 @@ func TestTranscoder_LargeOutputs(t *testing.T) { # check output - ls -lha - #ffprobe -of csv -hide_banner -show_entries frame=pts_time,pkt_dts_time,media_type,pict_type out-30fps.ts ffprobe -of csv -hide_banner -show_entries frame=pts_time,pkt_dts_time,media_type,pict_type out-30fps.ts 2>&1 | grep video > output.out cat <<- 'EOF2' > expected-output.out frame,video,25994.033333,25994.033333,I, @@ -2697,9 +2695,10 @@ func TestTranscoder_LargeOutputs(t *testing.T) { frame,video,25994.533333,25994.533333,B frame,video,25994.566667,25994.566667,B frame,video,25994.600000,25994.600000,P, - frame,video,25994.666667,25994.666667,P, + frame,video,25994.633333,25994.633333,P, + frame,video,25994.666667,25994.666667,B, frame,video,25994.700000,25994.700000,B, - frame,video,25994.733333,25994.733333,B, + frame,video,25994.733333,25994.733333,P, frame,video,25994.766667,25994.766667,B, frame,video,25994.800000,25994.800000,P, frame,video,25994.833333,25994.833333,B, @@ -2710,30 +2709,33 @@ func TestTranscoder_LargeOutputs(t *testing.T) { frame,video,25995.000000,25995.000000,P, frame,video,25995.033333,25995.033333,B, frame,video,25995.066667,25995.066667,B, + frame,video,25995.100000,25995.100000,P, frame,video,25995.133333,25995.133333,B, frame,video,25995.166667,25995.166667,P, frame,video,25995.200000,25995.200000,B, + frame,video,25995.233333,25995.233333,B, frame,video,25995.266667,25995.266667,B, - frame,video,25995.300000,25995.300000,B, - frame,video,25995.333333,25995.333333,P, + frame,video,25995.300000,25995.300000,P, + frame,video,25995.333333,25995.333333,B, frame,video,25995.366667,25995.366667,B, frame,video,25995.400000,25995.400000,B, - frame,video,25995.433333,25995.433333,B, - frame,video,25995.466667,25995.466667,P, + frame,video,25995.433333,25995.433333,P, + frame,video,25995.466667,25995.466667,B, frame,video,25995.500000,25995.500000,B, frame,video,25995.533333,25995.533333,B, - frame,video,25995.566667,25995.566667,B, - frame,video,25995.600000,25995.600000,P, + frame,video,25995.566667,25995.566667,P, + frame,video,25995.600000,25995.600000,B, frame,video,25995.633333,25995.633333,B, frame,video,25995.666667,25995.666667,B, + frame,video,25995.700000,25995.700000,P, frame,video,25995.733333,25995.733333,B, - frame,video,25995.766667,25995.766667,P, + frame,video,25995.766667,25995.766667,B, frame,video,25995.800000,25995.800000,B, - frame,video,25995.833333,25995.833333,B, + frame,video,25995.833333,25995.833333,P, frame,video,25995.866667,25995.866667,B, - frame,video,25995.900000,25995.900000,P, + frame,video,25995.900000,25995.900000,B, frame,video,25995.933333,25995.933333,B, - frame,video,25995.966667,N/A,B, + frame,video,25995.966667,N/A,P, frame,video,25996.000000,N/A,P, EOF2 diff -u expected-output.out output.out diff --git a/ffmpeg/transcoder.c b/ffmpeg/transcoder.c index e7cc16182c..b4c7a5451c 100755 --- a/ffmpeg/transcoder.c +++ b/ffmpeg/transcoder.c @@ -161,6 +161,7 @@ int transcode_init(struct transcode_thread *h, input_params *inp, int nb_outputs = h->nb_outputs; if (!inp) LPMS_ERR(transcode_cleanup, "Missing input params") + ictx->last_video_pts = AV_NOPTS_VALUE; AVDictionary **demuxer_opts = NULL; if (inp->demuxer.opts) demuxer_opts = &inp->demuxer.opts; From e019d826bd7e427934357c0979b31ba7bc061c02 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Thu, 5 Mar 2026 22:38:22 -0800 Subject: [PATCH 3/6] Add unit tests for inputs with AV_NOPTS_VALUE --- data/skip_1.ts | Bin 0 -> 40796 bytes data/skip_3.ts | Bin 0 -> 39292 bytes ffmpeg/ffmpeg_test.go | 174 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 data/skip_1.ts create mode 100644 data/skip_3.ts diff --git a/data/skip_1.ts b/data/skip_1.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ee2bfb8e2264bc726425d6d0c8c574b915c06c9 GIT binary patch literal 40796 zcmdR%1z1&Wm-i1XDJfl2(%mg3-5}Bu(%lVGN=r#dNFy!X2nYg#v=RzPBhnyBeEXc^ z!*Qv-42{_*S}_#j*e_9AE(L<;_b?1B1HP7=g{f(^Ph1Q!4fXaR{os1uD7Bq!&K z1CrxF0>6g33;z1R9E4UjevN&l^IlL;P!0&>PX!`Zf?JX<0{>sDoj8^#;JZa+DBNYz zwbDR^Z+Kt5xM_Hk_Ui7CcVgod=}L7x&YLG6a@PFhFDgOyt|ar|a^^VH_}N#{6&q}& zvQ>~neogn#E9%13dNK1ab77Y0*EXMTem>H!dtii5^~1}cWIAy^-Eq<0Qjd&cD0Jo( zw;UV@M@%Xyp1KQhS4WIpbk}5iXJbF7$64TRkX25#!DiE=sxO?LvRcJt>)$+HvN&8m z?ulZ?ESz{B#AIS&?5n*#JhAA1YjGQ$L+}85=hgmT@_n4%bIv3x&Rtf{t1F-{y z5Z-#U*UF`#&>q~Ii&=58!Hw_UdnA{DWxdU{^~T6G8L!8O*a+p-jlkkKjMJRHqf63d z>tn|i=j9RCw^@3%dI zL|o?M(RA`N1cEgFI^#ixnL1LpQkj%e-=`Kb>g4ctpPLwt5<1W7aNZs{Gs#l%azvq= z+&^8xdG_Qk_AW9(k0}=J?q!yWtHuTcE$I7308&i)svWvK+M~C4?+dC1UccwwY`M4| zR1uniwwvser6^FnUCJI5vYhVhO>Mzq-6;Me*M*Y8;>RIsQSsAivnt;mYPHQLGgU!} zCzPNEbESqwvBj%ab#QqL06X3{tX#BQ#=RBFUToB8%~zI=6uT{T&H~6oiZ;e7Ub0%Y zgT?J1BO^$cjkPc^)1&PaKTIyrmy7(EGcobDqIr9FjrXlA59O9YVovr`9B;C_$?rfW z%wN;xnpASRW6>qmq|aSF>LXKw_#S&JPw{8mJ*s%pHIZ|^yyODvp_ywns}g$CvmJ0R z+z{XAbA_$^vo@?W@k!NbWf8TM3cq0Zn{5FWE(d+qvr`N`*IZ|B=pSO|HwSwr-UQh3 zuF>6#+D(q|eTVe$qG=x*``%lAkKgs2qvG9{F!8E%YrPE5 zr9vK|kgi4+>cM2~QNwq}T30DDv$Ur9z8oPFqTxRs@M$%gAptqn=lezCyJe$R{r2l* ztsEMzZVOr%D3rM#EFUTg;JJyhy<^O+gz=?IBH93Uf+a?3O3n)P70kpv z;%pQ-lXb*f#rFzSl9+u}tsH{(@%*V6_1RBe*PQdaxzumop@@9f+gv$0@4jMs+KD|k zJ7H+ex_{lYbw8tHd`)PtBGJz3+2J|=n8{N_hov-vBZctCQ&iFvtyJd1d$Tu%kCoxk+aBM;Am;4t%AR(*&2K`Q&Es(7G~ud5pcG6? z>jHO;GC#i&FE13sJ9nJ z$S?bj#xsoWG4FGw%SasZF8@Q7fYSp_>8BGu~%3h^-O4a_E7ptOT&5Kh- zzeeg{M(g7H@~*cS8kR-ZNZ#7Uip`vdnIGc(r+d1Y6&MdUYr+EwXzdp}cG2R$E5GM4 zc5m-eKh&tZIdS{0wPfM7|AlGNIKzH+g@;T2=Z|7ga6~@=I0XN zYfY*v2Hzt+9DPl$G1YF_K(VEuFU_2tpB7gNup$MpuI1DiMG z-K?fRG2qFskA}9!m^gMH!=IS3h7sHeoeDptz><1hgzJ&@9hF?0O>;MIuf3c?v6D0@ zB;h7alI{l|#LbY8$UZgKINQn^=whS;uRT@C0DX(rD zQg2*}a6Rsl@jL^$bB*&m+&!23661kw8aQ9T;2=``H(<~A1m+;zX1_j z2%WqXYqj_@B}+qch0Z${`#44{pnz@9ELqo#=k>$$ZpkB!k~$4Ga$}MIn5HBMILXvw_ey(8y)G ziX>)NU@BY2+&ZR&!{ny5c%~pQgW!C}{-88P|8ap9lQR)L-9{%^f?vI*y;=2IS!Mo`~KAQTNM){O~DcCn9t-W*R@LvL$0QCu=PV1m}5 z$M=~c5y7Sr+Ztf!DE*bQ^sj9BpMAgmHVWyRrl!_G=q=AZAZIwi`F?c$x99tW_*I6& zW!XjUH1Es?GWc7va(}kQ67A?2!O-xvAXI50=_K?%2d=!nxKImYF848dVN`{In4} zywM4;bK7BqP(cH~NOy>{$UvM$9)TH#-zjpZqivePYbk4)ecHFe{reNcW*d&6S>3WP z=m*&7YkJ0yyhc@qOR0ke;~OWBE49G&3=fbqsK~{?K==Qg<1ij6Pm&80i3>0io&`9e z?+q?r9rQIQWRM54eH)52M!a)sSD7$ExRXqf3b6A69*K$(_Oljag?eP%4h{$~%By#i zOb_4`{}GXc9IB?WZv7NdhfYiuq@gOhv*aSygc+uLZ!lD+fKY~RV~Zv}ekwC8tKAgU z_1R)$aZuX$wK(=!0krdJI;7Wn))dx5o>YqjqF+bi)=a66KKBPQrzXIIW?D$9)|3YP+b*kuHH~t<={|xrjJwrN7)dWufzX)`~PmCa+2U65um@JpU7) z!Q<(DuO1l7#Be2&(WU1v%I(Qkn! zu9XtqTB|Z(g7RF7O%0u=g3yaYAG?s(Ltw*X0nnX41kfGV?6>61Us(jn9d_s}kdL_< zZMdwI$Q=cL6(aw4Iu|#zgJTCHKS-3);18PRN}SvR*agf1b|Q&?@mz(_DMRT@5n$+^ z0%!j~lK4HEXFCt=YQEq6pnI88GQFrqmbB^3*h~s9QYt-so}%TNp*8M-(=Tuk2^UOi z+yBio2nS#nvgihUJ;wMf)f0p4uN&ANZCqgcXAp z6X=u*8H|}f_+~Tg;&rfCAArP=a=QR_ArF9^=EuKS_rJ2^-@ZeF5QbQTB9bUz4RJd# zkz_{h)QEkn#z3;J@4GG>!MQMeZkOc3>wdI;9Cvf`?r{Jdm|Ym`GmIa8)?&s`MT#u0 z-bXS$l2iQK*v#%@R7BklJ_Y2E*G%i(O2p-?jwEi2UvZV6P~&k|&?)o;l|*H*~20Y~5at%WsjB7ISV%Ki5rY`YM0KCz4;2#^sJ9Qn~I z3HXc%Jm`{sKVggyPUEXJ)+G{`eEG(A8X1p%2kHBMGv2--c0xLBcl&$zF23F16G?mN zS7a+(zL7R;FT8)6ViqJ!ZTYN*yQ=rvs~Brce^%y?bgN9f@%)dcaRfcGl}D}-;L-G| zmVV=<_;gbTIrL_=x!r|!_>gB8Ltcrj*3NUKHuoEhrtWHC*2~TQp-G!N68i|P_dg|$ zHJFJHMDeMQH)-97A4oyudT8LYn=JQ9Q9v`!iL5?xL=M?(FryHfJCSBL*Gg z_Svg3hRe!{+zEMVa1mi0uqxnu69dul1*>D)O*jhzvf-#o3#Jc2UybjEP;* zgV3$yhd)YtJv2z^&edDK2Z#HdffyUzxm03`Uw)tZK%QcMB#t{mw^eW zFmmUaB>UmpReRd#SKKM-6e7klC46VAzL>c?Xx`>?rUKgGbI$Xch-7%|M??AwmFzS- zj%}_>dY-k!5wEJ^K?-;L2`_%0(hEmgjP#shi9F+zO_REn%IUG|62?Dx$TnYNh3p%~ z7`;+yGC0xg zJLURr+1uK*o* z@99+G4Tcm)Hxb4LTZE2I6oocV8~gd`Llbntl?uLd73Yanu`RCdsZ@3j4{Do5{8 z`xG+34yoTY*8X*K^}k_-kV4lu6cw{!NQc1Je39Xj6Y!zO>z{7nw%6IJh9h@cy51ldC^~-oQktU^e1mW6INzy( zh#bt$7XYvuJ^wAbgNFn{%?-7koezNR_yBhde=a}6Wxb2ssopeAY&YGkDh#(A+eNdb z&RS35)CeM!0NDA#K4TsPt!F?h(9oa2T^%o(KF7(f-Pm8bZ|-VbZNJpNz~(Q z=}1?6T}7=-;j9W@LXPa($_Q~r(%N8^GhTUYoCA3Vqoq~2g$d(C_a1r?igSxv(?v!w zu@&uYi}$$3EpPYU+9M{MW-p6WOL(f2MMcTJHRs5z)$?F$zrA!bO{9x)O|un5ZnC>> zX*tgNI)BRAtWT4K1)fSNc7jYVd;()B2>Y+1%Pk`f*CVN`-5M$o0B3T%g`#bWNhVM$_-0LJ|5+YU9^cObd6J zodI?cLxA1)_+O61ZOz`;6^%)7ss4AnV zUi|}->8qUV`I_EB>5A)bk&&PGG%UUhZsoamRDwS9dH)rX+=tVUU6G~w8pBP2!{%1+ z!)|G*oi=g=t^}r?+EvGvEqOXZqGAT>fxLqdMWg$!k#x~iSkC$FjhNJl*1WGpY_Ry? zZxrl@2jkk}iBX4E%yC(?dWj1uSrf!6UN=s|75`{Hh^*eT(n-_GhU5FiBPN~T>!*d_ z5evX)#HjzjK2y!F`JA(RK4ISdu%nFqeL!t^+U1>fwV_3cITFVo$^9-)9|Ojnvc5cZ zP9E@Xwp0ple9I|dQ>TAy`9St%w4!)jt{j)Pw#L-Kv;-|{uj9yS$s)T>+fw5FvaDvt z2io@+_XANnoJoX(%=&2Go}2L~;Z$h%&9H~{FsSis55xp5aG64TV*%hJl3# zuJagWsY)hDP@gfFTcv$NdZbU#&sBKm)ym2B_j*5Kx@3M-N>VV%XjT^Gel8sNZ0nho zUf+FJA;@T7z2WZryFKH4$*3Fi2tJiAv4z|+vE-IxHelVktO52k`M+QHt8BUY0TSFD zsOQYMeY!pO-lhhGxrsOm37Q8f?r-!8{ zNzp4C`9uIaH`r&g8Gf$MJc7#R_w4FTB{Q%&#myRi*vkmSft020F7Jj=J7y|WV>0Yt zc7%UyVd;qooIYH>&UPx{i1oTpLtyX8o8ubqQtO>NDOm9K#3*+DaqmB2=&hXOH@@(m zv#V*)tkUX4O}Pk1F=>lv4>d>|)H-Q3Aa~ZJoGNDJK*2{wdPDMM;Zf$!`NvtYmssUn z^$nig7#m7;!%tK%A9C?uryz&h8kuR2l{~cQd4q7xm_o@hWE6=m9rZaTeTopnh@jct} zIr#{kgaA8l{QuV@uLk|s{6LN8=H}+E_<7`f;D(!hmPxYV%`im-ekuR(CzDJ{B5?(G zCLS1-Sq_KX9)MwRGuk@EI7ifA}MW7kW=#`Nbm`UM7I| zw8-DEgI)W@$dlVI7A?b26f!Q({9`t{JD+sB2%bJ@d0q4)49n4#gGZORrgggNOIaJh zF5m>PS9SbC-61hZLN$K{oU{JANXljbyp%}0H?uWdoHdd`lisZMQqFX8e$4e#@IChd z{3r9Vp_)9W1ya(lY;H<|JyH;S-=qH7FVOuT4hG~!NHpva^Hf#s2F!ED9VR3;VEwFl zP0Kgfrg)7qcxEe*WAotvT@b)71op^QpP#iD2~>`~t*c`tGlYOO?8n=?QBBBJPFs_L zE8J+#YBK_3_(J@;vJ6ow0w>#yglnoN$8~5-a9@;l#G^ebY1!Lk&8>5!fS*DV#%Kk7DOu@#}gY|nWw>+AS1Z=QgCMit#< zOFf@CHRbsCtT!xGW38dXF{%1rupZ~IkyUV(NF7YmTLL~KEcZA044Ns<`%6`6>#YmrV8R+!>eRg&z1^8j&*eU$7{+fk$8iO?W=o2TE62VUuFT_ zMIHgVzqVA+XT3B~_|v7+5W!Tx$z^D=|*2Z;y}P_x)ae67EwtWfsnrgIF-M!|BDr0RgT96Ube_aLW7|_(neH_)s?i?BMhQG^P!H~Zo$G>mzQLzg z)W^wdq!4Bb1Qty&1Ih*h@1CI71#LTJ%AuG_Y240?=ij$+D9iQ5 z+B3NcO#$7x;sD)0z4$Y71{^LV_isYSKcMnz{Nb|w0JpKO@oi5!z(P6R-v-#Z;{o=S zN56Njf|;-WK;WsN@F{dK@BxuKc~ecHcg^eap zFohl1b9*nV$3!jjsP=lTZkd0}2&hLGCo$i49p>ELKG0Q&HqaAaM}MUJfTotWaQumQ zV)%ytx`&hTb_T|I1mH9L_5Vtrxk}={{f7lWcYzc@_p{4CBWGYDgrpBCGz|{UuhIZ6 zJ3MkHRop2a1z;CU!Umzi&;3Dj6&wJBoE$=~j_wLW9tn{1haSXyu?E zA>@=$@|znl#Q?i7aBf1wO8s+mhk)ON)HLdq<;;xxP= z^jXB3k1+>DWj4P&(oX8`#T<`A^y>Ib8*ig0^v-g;pT5~LC#3u6M}BtY`t5XS5q=rU zdr#?_2MA&%5w-eNK7)VnD04aJDNn*JUyCqA`{7(Vf#-KO z6V2TRXO}*&FyrLMqclvfe%Mfbl9|Epq~r9Kc=(GN>lw|vPrC3gW3A!tTlTzRo(?Sg!FwI&W2i8;rk>T^2K9Pu^`rvw z85zs6)U-+lw%$3VfL0#r!+EoU?WzPV!mBng#41d29a|mGP1?Riy!#`xMLJJvM zDEIMo816Y>vPzWk2_%rP5^+2z@{|DB`G9pXG%3?RL3hYJg|3TfVurzRe~jFzUszm! z$f*|4y%jW|fR$s7ya=%KgW2W9{si5BvV*$}Xr{~KfB>U`37`}37_QFxfEYocQj+v` zDu5mQ{3@C%+0T0B1TtEhBF4?tSNnW?+v1%<$ z>UaDkJ}=3FpH`Zs-FRDcF2N@fODx#;+U@=HwBF(81g(wy*~N=kMq8QJ<-ej%C7u&o zUXM@yAR8O3m*=k^GG^F~Vclm>(;JD_;XDK8XugyGAeCVr{u`OgI*S3qnlp>cp zn)whdaY`^Eis7+DwWB++7`#8=GlDk1s-C&L)Og5jLh(&M>#&!8Rrt=rJ-g;I8*dpM z6FeD=;b0nDQT*w55`vSX6Ahsuvd6-9#Vg{g!-?M9B7JayF;PTsMF&HyqbPmJTq09~ z_q+k!g<=8SwNC$poB?|bbh6SwhhKB;YWU%@mjRQZqr#<6{o;k~e5VN;U>64V>7m`5 z{>^h0lIqZXdYZO|FvP3C9pmYl8*g9S;E)?PYn**Zn(-B32yW~Ee9uM%SO-Qksr|jW zL(rk?z?vcBFzD+5`jl&jo9Wu&wGwxC-xFcwlw@LGNhumhfL#>qGnP+&v+nFI*MSX(5mhx_HC)=@K7dZb+$5Wg>_86Nq>a+MomDAW*#|!bT2xfHB*byntUbmjl zpRLt&6B+Ho2;c*C?;&5TlPEiC%hw^_k=mLGIWkSPpC{%;iR!klTJON+)o%_|AmIMk z`N^)Kibd$t=aHxA9|&+semqjmY*s^(3+@2eIVyjhM?x~=fBR)sRzm&h{B7`g6;i*u zDE|rh4vq>kYoYaw<{RFt%!SLs0_qvz2{^J(EUQb8iBYW%0d}rPAm6>1ey`6!#sY%P z2%T^XFJQ2-f#*ZV&N2`+Qt!LVwOKfM*XSmd1Oe>aV1Ey|_M3Hw_4jYoFxAUdeFj%L&cF!^1qraf^MGsMhbw=8?*Aq}^qNmQbOi?lm?cak_+X#(3Wr~d z6zv4q!OyRvJ?e(mVsLVhA=75SygEQK9R%!KIyC3|Ub*IHQBoX+6t7ulFZMwmUrF}l z^0+2P7en6ScEUmr>07q%EhhcG2&0P=^^Om7F`j~WzlSIB+?LE$&d;L?CUvg5bvtml zP~}vH7GB2uNd<3z7wZ_kT*HYp?+W9D$A_;Q zm+1K-!yg@KeNc&V*DK-?5aIt6F~B>ejC?B4l!^0LbHGQ$FC!lHSQIfro$uk(z{cli zA#yBa_)O2KT4!zH#Tf2A7$-)9%w{BcMA!_?j;+=TFI*E3biG^J$Ac zyvhK$ECHY#<5HHXSXR>{o&?whA^`T}fZse<|8hqrXwGV%!Vn7qHI4b)M>iJCQTFfa zE$FfNlBbHI^8mXbcx@>|{}0g};y2K>C2f00m;gk8%f6)`L959A)06V09 z&lUXrxtAu6OohcQ$C z>MbB0jJ~BIXvw_X!SAry)5m&9(9S+yo^s+u|FtsYw1}b9;mna0t#+hT>6vZniZ*?olZohn@rB6nde#bZtyiOnjo~vbte~j*bW&Y2(`f~J2 zGDsJocK_%6H?=NLoJRq6QSiB`X7nHG{&N_AdP2RZ^Z-a(rCrojlXgZIy%_1OGepJ{+hOkJFkt6vAV{9(^w)bKN!JogU- zp@~$iNR!MmWlpEm?S6kMa@;wr_tJ=abwlNC+qL3S-<VUZnt}r%t;Ab*29T(?0m#0XNuZ-qHOja}~yC zbS08uuz?0U^#>)~M~$>aSH8MMVV==uz32n7c|dN9;LSDn^I3{H}AI0`~!5Lr5he5%fK zKJd9o7<}Dy%KeAv4het`vJ|X$j~p{Dk`h3^yEhOB55(dDnf+>*-2Uf%EgiX z6t+IIFBmMxp9lLFaOcwxd$JurkMU;Wkd};b4cIvQMHR1+ONGZbl!Wz`iQx6;Wmi^~ zf&#Mce)Xd$>r#!tVHQcn++U=Bi^aj~Qn~FpazWZY>?+lGh9IH=hfMs$h}{Fhw;-t9 z;2z*Jq87izX9P4cYh?z{S|^o45B3k+$j#<+aFw#?-Q!tGQuCP-*qZ#Um~NCJ8KzZi zSJR!e6$UX7+@&U*@zej#ayaPqI;M}_cku9iSv>EoICo{bJ^?jk|2wcAiH`X756Brv zzChO__3&D*1|HH-r=9kxGo#q4(JRrrZe>*dqD$-GzCYqXZv}L;q2D%FA;jE}RMlhV zgdqmH?6jUUzV^qev6x=Vzax9cT|oAB?Eq%84Xyc<8>1<}&fy8LW0(AT-67CIQ0Uu5 zFwj6xorZf=HEgVqPw! zDQ63UdGWj9N8O_!_fr7g>q`C=;EwEN0APc4)&7!z1~H;<;)2Q zfX{FX{7-#`uVBt>IneZZjO|8`m6&>O6GGT@!GNenEH{Vr-570KE+jf^YKf?{Yn0sE z?@q>}_0gX&3ub)_xwSnbM(I)1J@3=|`K%^`+W4joKdICFM&CY{;dbd!HXik0u`asx zNs#t?=Yl>gXYzbQzv6>O6Zmx-NB2%}$TQqs5UU#`0o{250o`xj{(U(ENfs_>-akUU z8gEFuoHpS}-;9OSi|-_~7O8=;AVEY??#G}$lt)^N;Pn|^7l8d1Zl6JDzAiOF_MIC%&~oM!<^&B{V)L8AT~sZ^U$@|LRAI>rZjXFQFBE`Xg6%+7lL zOLd23Z#aTs2Rh|+G|j|tL> zYX$Dh4BkJ3F4zdIXW%TL1G&0*btt%ZPGGC$>ydQ$+Nk!(i#=1ld!yu6e|ZPguspfwga3 z#hk*7Jd+ml=-Ru*b?A)>%i+TTpCR^kPfYH_!Q*3eNDzM%_;6@$C^%h9e!Vf`Vcm>e zzm%|F3s%CL@?aGv8`F!$srm0L8}=cBMwRxuEP48T^;!hz8Mk0MM8Cr}X z{4&-Ne-Pj2ad@+=aO;_U7D#y^RCC(hVLGamQ7cRO1_G6 z$(9R2^Zs}QQ8S|uvw{Vi#+ROP0?9i7yNEu(F5~*I@fir64unph;4KUt(Cw#42{i2& zFcqDaRXi%MWzictEVq^ucimKSgsVe)=J?a_J2bKu#4IN?22l?M0ZFi&|A3rtWq$c0R4c<5_kF~M|^h;)LdRDxEiWak}VO7 zAdClE6W)buE9k{V)7{C}&80Ay?LN@9TpB*H1=z)$0d{q^e~s?{cqTwXkbpX3GYSp} zutk_SfL=hwGA1<(rjLnk_+M&=e;1OiWez~mu}Aax^p4cug{{{*bGWP>{0yi#*duk( zf7W7_(D5~3zj`xp-=Dybyn|e^2||Z5U0KqCh-BQesC*~w6R#HTV;F(JJEEJm4xN-z!8_KaVtNdoXMHsDB;R4a_?lrYt^lP`pC#J_RywI<+I^Nrv=f$|FVxF+$zdSKa-^uH| zbD>7=Xj`cg@$fFZmq_e(Uz+k$hMN{Q*x_~(;BFanaB3o>TGProPSL0U?3^$E4|=3z z^A0%Qxg3Fx8YoDo)BHw$Pue6OVCM${I+zyTeECMl0gK>E&;~_s|8+OvY=eQmBC;NUod@X4 zjBb_x>vaF=J&;(0q4GaZfQbd@aCBrmm3FVJzg+i!Y?shiA#p(aaTu~|1E$%G0uv0-%&6#~H?S#Z-6qdlm&wS^6X1PI!#UDRQ1;f2Wu_p1YO*G#AU}l=>bo zc_|*W`#_>~^rZBK&*7NU{aNdMa(p&7m5#+ZO}A`RD)~^Rl+`&3Ny^z(^B)4ejW%0S zw@OF|HlK6Nkg7S}`;g;h(sy>IaPcjL-vVpkcSj4mdo4|~~ z7yEux8lH>BQiR(g4JEP_tUTByqT3^Ld zWDSE1bOkC)*U5^CZogq)!uvu8HL7SEKAu$qU>5?fM~0pJ0lGuzp$kNYgr8vOfqp>6 z^Hr`~O$ME|?`jUtP0-U3v4R11Vcs3S9!K*{SeSZSmeq3(3_nH&F%Uq~tl{#+ob4IDB zxi_7)`fPrO@3pskz8eXzxlfTj^^&36kNmXm%2KnXJk+{NW@+!+watvn0-03mJn0fG^Zqi81*I#ZMNsXATZGw$ge89L99|*E*z|I@~pdUnS! z`FC_O6hu#eUF`ql^=dpfc)yr9aK8yXHU7`Ycc|My_vslLNnRx`q;1iWNfLa1NmvJ6 zIzt@lC-7Y5hyd81+5di@fy`6reLh3?1{iFhfzgpML$S-V&IFZ*h@U=W#NI{3OY z1RQ$rz?gUsu*dv77_48f|CztP*`y^98qaxRQFp5$w91=^!~{D*p;*R3~A-s2%e zcnG=G%;2|0XE&y2KZtTDFq-IGogc#s!WBkkwJY6F3uIsvd4SE;KjO_LpVmE8vHVPn z(<9?T;#8!f8=UXp{WIvbpMQJKTxkPI9%wCQI39O3{*azUHCO``5B3zZmMz5=0d_vH zM>co;33C;|hk*a|$SouoaG+n&9)`YBI}N!PAteiHZw)x#!JprR-kJ4Vb%zi`KflQs z-WP@#=u-4;F_u{JdfbSh4ccDBpjxfT&J6MU^jbo2&Io`#^0nWepgSZ4=reJ~apE{2 zz;9sU06G?Zf$uP}G;!(okW{!v4X_J>eP&4SXDtTZL2S(V;OZc7&mwmD3>;JE9qjJu zJk|X`k*~&mF2`^h_-2+EjE&X%EuTMo<{`_QlQ1FD^y|=Hkc82d{m{!1K-ivDr6!$! zKsF2-GEjEr@H_8D`^4TM>uqCrU?rY%5Ee-_$H8tNzw$V;arhNg=<>$r=$_o+3^RBy zE50~P!JrxJI*Ki=A87&siOIsO!SOU*&O0vTl$<1*Mieb5fX@g?{;z$;M#SmF#CbRU zBx1k1TWa&bKei#b&23WmKF&J2YIE!1W>Y;r7KbYfrts#Qoyr*V;~+s#II#o$`Wxx* z2W{1icLwX((MyGL_gKuN*!L~)AMrWIxl)F^58eMlm6E5*fS2;dPw4)I(tT<4C)G-s zMk{+9?k~Im-GzZ?Z_&s2|Ad@@SPdj`pwHeK-6nvj= ze*gFC4iOvr?5(lq8yNJ*kt5#(WyxNz%){?;ty3vFu~ic7BhgIWLG1(B#lX6M9{DqL z2S4RuybXN#E8sIQ5kyChRCM~^)OkiEh^}LeiH;pLy#w}l$ol(6`OkXhXY7S_S4V<- z7O~6HUkHMP%c+{K88-*Tw#71EF!M~_PPzAW5-#`1GN|RVJ!?0QEl_p&U$EjKj>!Zn4mcdY zQON8!Gg;cs4&thP>q2)@)SxoOT75WBo0`<{!Ks|4l;EpVp{{#MKuRV<0 znXkqj+^1;du)EWT%eELzI-fT(@6D1iJ7-%bz|Iv0upe3d-nshIXjmW>xbb8z40#QB zwo2)A-6IZLVxKmMKX4#G}w-Q?FJOyB&$R+)cG7q0^6EgLUTt&P^C_G{0N-e_sMa&rPO?F)%^YM~>`2Xc%xp zwHKpe`Rwctu!Emp#Xtf5tYh zcqY}JF}iaNmrYF&@zW0^+=y;1UPf#0qGmUeF-LFIetpMZ{v#6gAzgS2a?{CWS(Mj0 z+b)xM9DMiHOqp+)b78XG@&`5DDV zfsgK9AZtVyy$=#Rn*R>^aoa}XI;I{bk({$m1PlMsW;ZiwmBzE=(%R&jX9NdL#0V?t zg4Aa_YwklvsgaJ5`u#DWJI2R9B4;3(&j1~IZttt12RADkX*1$Ft2dA#B2LKX-NZ|^ zcnRc;0I&{>f!p#M=jvan4k3rG1Dh)Bz>v>Hj%=YL-&gQuNW#|EynS{@pQcE^3t$%n zdoe0YaX^6Cz<_@Qz|qJBm;-TQKl>E1$v&bqVR;^N>}mqA3jx=w7!)3V zfbL-ZA#p&jS53WVVM6Exhd?y(V+nydrL+)qOL-k&7bXDMX|?{N?vOXhgr;7C`qi6& zI~cL&u-hF>hT{io`IF`GI#Ic}ILJZm>OnV=?rMG-p(l zh7fy$mgt&{MZ&;Q_W7&L;Y>&Ro(n=l(e%f`o=t;A@`UM6s(so<^AFFm1`TfsXYHO$ zHX(*D!#Ui2{P7D48lCax{r#XJU1pLrMp>>|MP9vDoVe~9i^+C!iB zFfH!8k`3JXXQa7981%7rin3Z7e61}CJ~we(|Bt%=HGzKlcgTSMJU4ZU!jP{5fQmsluQDsA#%OmL!9#vW3X8|c zeo}*(8Y%axk)1Oa>+5XFNf~dp%fE!ji6WiO2fP6+Fz2u367>d z&&|Zhf5)oxkXYM8|;qTe+v-vA=Pojj59f?du|gKriC>a#fQ=qR(Xch=7`a z)9#&wPndYdm|8^P%t_iq+XR|Qm!v2(;f#f{&S#vWA zf~(O7_s$tg(~j-f~XC+H5HNRV)#>+fdfo-mQ%M~*b`l=%?!$@D!#nqL6e z!TV=0jDGxFjPLftmwIwsGmPaoAMuSU^th`Y)-njB! zD9Cx{=&uLz_$w5t{;8bMZIS`tYV7RXF|sRW;1Am?3wZdM){Vn zq?kU_cP$;FXP0nl3UZ0&-X)+L5T>(rSruD;IYRI6EYH(hLN{|oe4_k(cVD{`RkkXI zHboHWQFpxzRu<8lZz_p^&+wD~w?4B|%gHUbY&K)x6eUCI=npeS$H*Fsi>kE=5Fuj9XSPpPE2h0eQ?chs)^^+v*2 zm22mtmdr-R+KWUTmhNxS=Oi!b*ug#{0PKapFn|ANG2W_r#lHVa4X_K^1MD{SziO^R=zeBq=?54(A%Lz0@ylRGaPPG#%2h=P&rfj? z1<$ZwJrtSp3&aQszGREx7T%^CNhf%JLz32c7+@DN0N9-p|3$h(P_-dGV!{6m@R3)l zFuWp>l~vog6CA{OAG|`1Oh)MX@?w$#BaMr{!J#LnR^o@gxhQ7cEfwgotd$@VZfECA zNY4EBLq4GjRe|vfz%FbJuzNWF3f&=r+<|7qhhCT%#3C!JMHTcej0X-k=yh_f3Rcv3 z$#y>LW%HqjzNV_or>^~&1Tt*$G7?l%9DjoHO!=m)H+YQ$ynhD6Pvz$t2XqPCY)|;= zoxzQAj9E|17Hdh>rJE3&I}i{Y8Ej6!Y%1P!sK6<+UrGpJPv9JT9};g)PW^QH=7K(_ ziW7V*PMMvK8k~~r?X9$-<56uIQ?!qLBhdoRJDA_=PtxoQiYSX8$-NKge;q6?X(#GF zX|Vf&9qv#~VbESWch+B8RrW?&MA1=8bLT~0eemgc#+0cNo~OFFK-df6q>z_dVY%SX zvk~R_Z++&!C|;0t!UtXAtqJ(Qp=>9HYF+NnvPat3 zK8Tei$#}2^`bvOyu&}z|Yr}_IWjwfrOJff1DJ`x5ut%c)Epruu`v8*A7E}x{ zxYEG-$~xJ;%j_{GYZ9Y?0sJG|gaHzkM=FIMOS^av5YP;6JKOLGsTG_cWw6;}1qBW( zQ8jy7`^e%4k^0ns1U{;MbKltUHGU$68!8fhbq7xv-bDt4hi5s)LP26 za6o{+z(gSnlqd=j=NYa&kNO^jhKeR|H3t{R3hwzGWjf;#Ut`rfB=gp@?|&-(grh3X z-6#5er?+SGT4XD&WHu%{(Ua4a9@)3sA)-=yngBbe6~La@^S9{!UrdglFGGTohmMLW z9!xxUfOyK-B=(8+ryp2*Pi(=5hfOzH92O8z`c#RfzOG<$PWAky8q`SU?*EZN~GJ@flfY>h~(s=j`Q1^m+cPf)s4+rzxTSa VfKQ?DGmXx#4_*g!r+i8oORy4=e_&xIrqTZf9TroJ(r)~cl>_eImVb{{FYML36<@xhV7A%r~+%!B_K<}diK<|Z(#S3MXU)AiauK0fI%m?tfaS^;HJItTt=Emp!=L%{E* zK>;X7JxpC8UPM+I!`x1uj_+1w+ngu z?0w2_O`}S)JEdQjrMV}{%_6B}&mT~g2$%{Q-kb_*SFoYCMMYvQMyajVeSk~p0CMn7U`S#9-?MGeiL=6WujqMRz{$m>*isG z!R{|nDkr;?sWq!RjJKLI?WO5?(fNB!zML16l@)D$iSId$k@bi!3$PP;Va$}|O>V!- z5z|rh4<#cg*vfcFPH|@Zis9H+6IN#I*Y6DrV=v@>Xzs5wj4g$;RamWxhWJXmDGJ11 zG#*sGAMJDj_48WBaw~a&-(!^MAUh6pbQ=OMaw6 z>FA&MtSw&+l@9B+{BC7u))h(o3}bmmTwOA-S-C>m+qjz5G~T6yvC~z-byh`&DAmcB zCF=oRN+G|-#Zqsv5zd0OLBIZrs#d^G-1+UK4Tl(RMSz)yzD>Ig)>Ug&D)4tjI%IY+JBYw>aTcPY@nW{lSN@)t?H zVfqC#Gc7OE#;No$^DD`hVa8vm5VM=Kx%q|o7OP;Nn_>Ip{GA!2M8dsDRY@uirBC7( zG_%YDfSrV;F-U-MdMUHzL)LlsDPw7yE;DZYl5D(;jR@}Lf(eq)$lm*4nn*XguU;NtqX~ieF|P5ok%#xqy>={2u`Wsq-j%F4 zHxzO{aOxeipYe;gmx>Q?!?@qhC#;0@XzR$~uD-ch+Ww-zR}Qd~oD*J-mPZB z-9R#ammWRQs_~YLA)B@^)5azIK_)ac0|f$EFY1z``X@_%sI&Ho=VrZ^dn)_K4W+ax ztSMzyls9;byRSH8RS$|tX}_4VYTFL^)_LcV6R~jIrIpycXHn)egB=tU;mrO19CFcZJhernc1_GSSaqgfN)AjH&2)C{j-F{{5Z$n?p5RCYY?W3!jUwg!zl`J=9qB z%rkbt^8KD_9TngsYx*%Np}AlQu%Fk`U}Zo_Rjer^s_bX{oOFw^ieoPqGDLl&zO#%C>6JfJm%L9%q)AV zzmpbfrt(Kzk|lHXtGr$Qn9k^^%bKvA3wReAD#Od+O>tr@7OJE^iuyA%H3eLpLcE*; zfL$Qx)cZT)*2yWrDZs}gz%9tn$H&DB^8x-Y2>17#Pls@y9!JrK{5_Dw2Kaj#4gAmG z{*EGt8m;&GdYyI_DgVpL3tCZLsIP8^{7$2?zy!Jr zodvqz2!D9C{>@wTG-CA(S{dq|;=&`9s-D{GHFeH47bvslF<7#eRlkX?-RV!Z>t-J* z`IsJMibq5Gq2i4E#~rph{g&RiH{35sH$$E7eDu*({TW4MA|e_4bUl}ko?FV3`>}QPy~OYE#MmKLZUk!wHWI5eU?s@usFwOg zsCcE8>CHxFZpQ$dM?-UdMC#CvDqt7&0_;`yPE2P*Tcw&De8F+U>6Gp>~&%%r~BU~;7}OMh%gSek;0G->MXgCFn%Fi zbJ(hDnQJfwmzOOPuwR4wOfw~79*J@p;WNSS31Pr&jxU!?ROb?RS9JEXI}A@-6S1sH zzoa6Zr>U|og(A$2CXXMtE!t5=jJM!W@vs)bOR^jD;7C}r}AHUM>seWq4Rjt z{*?mL*q_&KQ{A7py=6Ju^*V}#bfk07KhR^e*C5TtL0M0HuXX&_K0VJ+ALr$CyC$8$ zMkz6%JLglN`{$k0kTXY6(l9|u6L|H<@u;JuS_gIZWRkubiBHw#k1UDhSP;IE0ob|x z0DD*8iPb7R-63WU1oL$*Bxc*7&ONky+CrvpUn$#RbE()qYvOW`@#;<;&8r9O+yQ{S zujGVvhv?Z5^xBRpkvFr_|IPq(=FFMH-n(K5;Hy>;A_ce#Gs$gIQ9{Rs`{mzl| z;h_`5j*^izv9|u4lYp;=vGKtazpGTvROxwn1CEADTmyRV$%3ZjsWalT=L!;U z=2ITA>W;*uMTrfBxCkhA*UTP7M|w%V2@;Foy1hW2&T5x!YySiI44?5o=`;VEX#eTI z5*DsHlDDq!1z*uH*#PBY^I63a2P%pp?0UNSO>U zh5s{`MM;ef>a6I-AO2Pr^>K1V7O)GzJ@O~Z3D+uOv>@s~9$5+PoPlV$oCY+uH+z+1h~5h*b8gX_GpyO zmb5oh#Pe*CI%*Z7W`q(wm~9h@`UNnWIhe;n#KvJ*oU&DV`^kvjXhmaCS1m6W1M? zU3UVBJv*qgxXsL6paNaVeYD84DSSq+X7fiZd@csxe{&DLT*Q(`P{H!X%L{97hG?j zpa1qua*#SRS+HCbjn3y5(@JrCf=Th&tbvO^q(1mhs(JD!H;=XEb{WV>1*FZ06yRU> zq_NDQT617Nd&Slh_qA?+y<-FIr0_1Exq|Mk7gum;%qr!{^xC$AbA^}PQ@erBaPj@O zd?q^vTTow@6TY8A?IJ_K*(btK*|L7inP*?qMI zlRH&CXG%shGYl1{bp57`xz%neSaZh)UjppBwt$`e&A&}|i1s>y_FWYcZ3mq8MZ;qM z6MSs6L~ElS?xpIGryS@PpDku|)d?J933}iievTd2a!Bez?yFTmG5P96&x3qQ4G& zW>GYC~xa|So`KDC~F40lEV03pZZ9kE3 z+zn(zc}hh2gY{WO?@)W(g?0BvFgnf)(Uw;)6u#YB+x9xRmrs80 zV2#o@m^+F8w)u8Ip@V0ZXV#85r%djZ3pF^VsS~&le=wSwxHOw$39_1OY?bQ0#%j>N z1$;)p^uOgZ$AbMAKgbTE2uc;-sG=*1#EjZ`u7I_hHi3hYRb8fM^@Te;rYMmp4CmK< z*rp?4dhD{zNOqK%`iN;rBo!^g{h>D+^|~SWc*c+N{V&jZswmpO--0GJD7KzPTX^C`YUp-E}rWNg5b_-Ef3BWGo z2H0ij|82F3JWdd|0F>k*6 z5_5?XzV;A?`?~_liRu2gSfEG{{vN`KjugpGP-nTYnMZU;)eVjkE0LITy0luV2EZ-? z)=h*eV<)2f-?;z&6BGqv-4t@u6Df+lpibiy9q)Dv$G6IWT@(kfYgGTy9l9zDVK(0+ z31Pr-jxUu=oZ%8T@w%>3Z+_Xy>wDD>=6QjUg|&2wZo5_= zYzXxdPmk`9e28Z@Aug83%AurP4C;(7Dl>Pfyb##NCI;W1xdvZ189zJ)`F@-?5Ithu z6cRRuL=OYc5J*H~ZD&a&1AG9xcnn~-ymNYVM}z?JQz0xU>kbnC892XJ#su7FIN|Je z+^0!*I6X3ZbsG}+hIv`jt$r0DhCIvMRT%>VG!F`VD9C* z%#MW$ax85U?{wG{#(ex6Uo#5!4f%#T6c6dAYz801D;1L!B{?hgzQr_NHc6I0uVl}q ze6jkCWTLG9=%~t1(`Z&Jr~3rhx#7CIEuUuH7b}jnLrG(XKQ~i2 z&tnMKd7=QjPaBf%$2D_uAE2SX2xWWNSvDl{D?y#&+sR*SQFmJbJMS~V{v`L*=ni4i zA+RYjk+4}oqO0+s4~@O$-4C_Z$^h(q(SSYl$?4J^QL5C42)HjGMZgXskfTq#9Xkow z`DFn6bK`$ncSx`MhzhSAA%uZ4KmIEvlZ3fAGBW)d1^N=$+ zc6KcOzTmO_xF~jZ+*sv}r3OdGjsk|DRs&__;`2L)DE&qS9D(lIW8_BTj$C@;Ir`Ih z#A?FSaDNww1?=&Xr%d-hCP|4<-VpUz29z|_pib@sE*-eP!}s3_Q!oD0y8jCc8yZ5y zeKpM09SK`KsPn}e@n>p$gnHReayI!^>7{`*3s`0CKiM~zvGP|cH z!+H0<&bDHAxvx#R%ii-tcx73lb55fsZ`z}UZExRI>EG^e!5YNtbhu2S)-NzTD?0VK zCcFQzo;fiTEyqHq6RfpGmH*Xi?IrdaSE}j@$@UJ{S4zC>hv+}iqwRHxVx9jzx)h^s zviwp1+q=1lA9I)3qiJ^B3*{oG*(zkp^t)$uJeW}VZ_iSNaHff6kH$-Tx5pOp<-yz@ z>8Y0I&M$e#i64;eQ|yWMGG%?@9t66J!SAbO(WfJ4{wjDVcmCX0`_ztyA0^Ers8f#M z^}VM(`6__@8hq{X{=q4z)j!}6H)8D(w%m%u?FF95cUy+Y#ZP1((j~+9u*Jc%VZsKb zQ=~ftk9am5jw+4>e=n$WIB-n2etw&4JWT?ybHe8YpV&^1?vVTtKVnW0PF04)Zv^<= z)l7Iv;OE7-;6Brig?M%VD@FKB-XlU7Fq`9RB$I5p#65;_pU@Ahqh?&2roHx#N?iEJ z>ha;>Bc*0~Nx%1oHyvj0_;nw!$eX}en>>PeNjZ4ynd96Zb%Qq)5OY#a;=8exD=ft&V zIs4s5=pMSaB}nDHlX)J4dO6!Uc`^q04EO7Qwa@%jaQ`E}KP}JtSkoVCw+p{F@xb?I zy5~++&OjN1*q;fP&o~}^l(Z+HT%UZrPimxBeFd=d!tbjC&8MSQA!x*XHQbmC2|5Tq zEzsKZ{a875FdC5SqhSs0zk~CUgrk|KL3fB8aXvEKEewhLX;9}XC6Uu$GcHwL9@izG z9@y?KJJ6jU?lV(9r$hJS5<&Kv=Vy_^2nAWwUpx_$&;Zy4;6AgU|L57kH^^rqFMa|( zllSQO8p))1F7c;%+KrgkmOf07F7Z9ol?)oUFUh3tB}}J|=vy$S4Sic1dY!;X`q9H3 zlhsgeW`Q%SFFQZ$Y%{Ho>GlT(UvKu5Jn!Uoy>)`9SmSemky2%^oL3FUv#KD>yu^ci z2^70m*_=71#opdVCF5887-=VNkb?9+^D5vzv7o@d&y{B9$P8JHiy|eO!c1t%cTMnt z&j^N{Dxc|*FiY&7{pyCZEi`sVROmNKOie`;4-fU;inXQdf-%d}TqA~teAJZi5uiK# z{0!kQkyDj3$UalQayY+{h%dWA>_<0t;OBvb;p|%%PD8E!eYXGc$N^I%_F_2u zPAJh$x9@Ov5%@fEf9q7~j@TwZ%p=2Bz9YexB80~@4fqAvMdJuyL@3`*mG000K#4;E zEw&@kRgn10gE|#12WSCyG2k;q=tY0#GbIS0i6$k40kb*2Kr-nCm-wz&f#bYV$XhwA z_$k9#y;ZV8Th7-l!j(!4)|Fy+v1;uYn10V|P3)}JXMghU%KVWPn{C${HkZLP{`rBi z&2l=PrKmm+DkTMxUd7%>B>nxH4}3zI!+TXM8Nw4dXFhYgxNdp2V$&X26639-`p7Gw z_EO+4S)h$Gt%W=sVn`y~<$qZ#`8n}5*PdnLXvRI@GuPgnDxW#d`G52wG>rapi?NM2 z{0zA`s8u4|@KcpDNInzEFLpfoC}~4rlcN-+Q2Du`|69P$3HE1*h^J^vmF^&4Ab!Mik4RG%B>vH$PKjEr*Cg<@Hg^JG zr(rrxy8o5x5I;G>X*|-9`0Il@>Bj*(j|5<6K>a6lhyDr&$e^Myje$W$lOF#)l1X2< z#JAtoJu6Iopy+5*Ay#8$Ld_XCWRtJ74E;cUDoCTg^K8Sw17vfjpg3o(POYQn5%lwY6Ee>ome*&-z zfq5j6EdS}!9a0Z5kBp)nJQfZm6%QP+&8H4@^Oj5p?80z%MPg*#|D{Fu-}*9Sc0na1 zc7mYJn)#IKuxOe9z%CLB*wt1~iS7_I1C--YO3g^nL|~I6qdk96qgegZE4N%)r+&aL z3ifA+v^q|Q?hra+eeJ+2d0&s+mw+9bzZ<;z^V|a+QK9Qc zphCThjxUx>gmH;0h05_XlSh{J%~WJ@OHyZWHeZ#JB$R!ANVUTsrzBs$H%L7zW$laaQFi?M z@yz;=S8Wt*L5}bU+D9MM9)5h=@}|cuNk43)cf~*pb4AMfac^q)ywbC(YMzF z-NnHfLn71Q6U!MWO%P`cqhgtkM;awn5KMTRxgRYVpxf#PV=Sa>9*7aTZ}%}JJlk;7 zCkh<{?3_M;{l57L)+$8Lg{aThDM<9fL7i^466eMf)+$THxna4iw1QrVs+GcyvpVY?7O$rZiJumFoZy5W>K@~jLuY; zMGGtrM-s4egR{&;4=f6lBFAr6h>7uK{ z?pXqM9{4_i$I+jCf-=ZwVk~b1pIHZs7o?sR9M#;f`hS(hbvR!c{TZA?A<7VuMVO7U zD-kN|WHwi(v`38IPx)ft!$tArx+1<*toXT$lzAIGt)u?0n%}=JNY8C-RKDMHgkhie zg*rNPFzik%o-^S&nO&+((xcCNTD++EweBBt;$sRX$exxQdDrE&V2k=)V9dl^7}_3M zr5r*-NxV$&{rt;|=hIdE-*G~K&+xwbpYxgj);;zAc#(q8`o-Mq1(W-+(EJ^o{UP!j zIq94^CWev+arP$$+xd9pp_Uf!NRiQ(r-S_;g)#uUfIDD+7Jo9jLn8A-P7@<+ zhy)HAT9lbv|ja=Fs6 zh38Z_x+K~VI;=j zU1OK+S?SNoUsTLqs;ghX$uo)V4-TmHIGb$KcwmB9FH8*J8>gJ~Nth zd>y=}1;@((>0ft>Q#*9FzblO{teC^F1D?{ekLA2JvtAV2BOhoUMsq^jWKz@q=5X!- z)f`iyu3Nwdyp}UYDJboITNt`)%%T5@E-XE^Eq zEAtt_{CIUf_sOr{E|`ZNq9oII$i^7fT`JxsASVz>%z4SCl#Vrs6JWHRWiP{*U^QUZ zkQ=fY{XfJ zINU%aY|zr8W@*xpr~K&yG;@EMs6pd11gn6Z2fp@b6+da+A#TLlBaZ$z5;y2$QPQ8r zTBi0!*OKUVb*Al3P2)YSN!qJ`ofrPRsgvq7=>FFTKye_RH^qrMBEfzz|21= zR}(`5*!kc-^KI|Xe1;d{Gpz!IFkm*vmq9%(v}$(xT3KI@dCAbq`lE!ls(Vj#6I<&( zxVEy;Pu`n7q&3q=vu&-2|7{hGy>BbPEyBW$8>QnxX>^?Y7RM(R0@6wApJXkA=drH^ z9)3K7L(;T#JNeODeIhSYSsHc?+z08;ti#=1*l=#-`(Wj(ZVAYb)eewWkx}qcp0C?A zVAo%wagq@JEhYfVQcpfQC;~pi&-P#OnZG1C3Sz(7rAfXd6nNpF!!@e!qPLaHcwF>& z3r)`Ez%|;Kom&2^C6e5mS{FmLieU*8N!~Z>qQZ+gml-?U?q#^qe2Tnl^o9f-CY$i; zmLlJ>ixWr1U{rR&9Y;)-%gNahVs2B7I?EB(BwjV+u-ad&lbW>q#*g`7#&OHNv3oVV z8y>#5oXOl?tqn&{0Nn-P`;kNarzB^fbV2M##@#nK9(SmnMNgiYy84+PsH?4mHaEn5 z06R2)A1^z}T7`fS^Y=KvUL;`9$)YD;Y_g@~Ii5W|CEd<~41R$3(XPhN>wMSXJ|hJB z{t(SPKNY$|K_L46#3jlj1p)e6lnrI}&e)Tf#(o@$i|Adfst4@w{*gpW_9v)V}%YbB)ilavVxU7b7}t^jE!8N%L>qUS@U(t)ish zSTjA`R4^lZxz@Kj{Ay}<8aLc(#HWB|UW)XhTME+?8PxOLqb@(5HuSMxJ3B)2c6>1H zl3@ZNimp6*1W^d9bs^vQ1MYM8o8*5!;Ze0m)opm;`twRg`3=liB_iN6qJ;m!XXtB+ z?lSwhQ#M>DkD=v~Y*Q%1k7zk8$M5Za`svaXzIU;=?M==S?WlVqdsnT40*sHACzk}L z9bPYN``#EGGs>hUz0|8eu${}V9cwF1Y=Qmg(!35v%RIEn1%eoj71_8DEB0|W@6}E4J_NAC&%YC&ojFCiL&b_%dnDi|Ai;NlV)YC+ z+PP+PaiCujuycave8iYdr$=|V_z*tg8D#|mn9cD;@SYaLN2)#9O|2bQgj%0s-qgK9mrie-`+mf~xAOr5yTYcV)xjg( zkC{`EDDjh#ZbS;N%TvS5F!mcQ%DRhj>!j%D)DwkkNN(cW33G6gMo&o)X)M0k*E;yZ zsJr`+!KwB$eQHTH@AsJThA1^Nslh%#tOV72LZdf3!sIg|m3j#OEqYeQJ6)XUdxx9wU-D zaIG}%Xzv%FXdae=R8`AhS+OYpKplG@^8$VMXy8}(yT2J#h#1Su)VKOqC%F zNdXf>+nLyFijSUkuC{`lfxo{>OzL%N@*PTb#F{VR&i--MLj5`3bNlxQ^;1XP8`U>~ zpY7xW{Zff3bx+i1AYMej)C3y`Bwo;$V-aKc;lhzp*!_0F%FL>v*7Dic_l~k?ny|$ zKmj1mGAAVKAqB7(R5*X6v}5CDOqT)J;r-u5{9~ODhktQsP7v9bQg^JY;*a)0+31!RwpR`w}I6`!Ea#y3ohqt*b|<@8VOW?v?5p zMsef9?kGP2P1p;~U8b3z6Mgtvz-uz5*m_p>eastQEv2xpsOQTN>rsjKa$Yaby|nk&qPqf3!daPbC{67u)i=rSH}{8E42^%6 zy8v?~Z<}M6`9i}SQ>-;DRi(idXOV!t;@GF=W4TUuFTWClpm>ADc+K?cQR-wqrCp+q zc|)6z-io~LucK+#zAnQJJC1OmyD&KGM$FxPs&WP>2Bi(+Y-d8-t>XlMcN8gnLN<0L z6~5*Z0rN;=!NSv0tMGw_xDoTngvmN2Zdg!ZF|O9@3)Z4}XYT=aQMkWf3p+KsL*Re> zeUA?b{7g_`)tH@~PJLLCza`Nnc&&=TeMZ{qH0h2I9^o@dgat_appi&T

E_{5f-4 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 literal 0 HcmV?d00001 diff --git a/ffmpeg/ffmpeg_test.go b/ffmpeg/ffmpeg_test.go index d0224a41aa..982044b9b2 100644 --- a/ffmpeg/ffmpeg_test.go +++ b/ffmpeg/ffmpeg_test.go @@ -2742,3 +2742,177 @@ func TestTranscoder_LargeOutputs(t *testing.T) { ` assert.True(run(cmd)) } + +func TestTranscoder_NOPTS_SkipSegment(t *testing.T) { + + // This test case exercises 2 samples that have produced extremely large + // outputs due to mis-placed SEI resulting in the H.264 parser omitting + // timing information. When a segment is skipped + // (eg, jumping from segment 1 -> 3), this led to a PTS underflow + // in the FPS filter (AV_NOPTS_VALUE is a large negative number), causing + // millions of frames to be output. This is fixed at several layers now + // but transcoding the samples here would trigger exactly that problem. + + run, dir := setupTest(t) + defer os.RemoveAll(dir) + tc := NewTranscoder() + defer tc.StopTranscoder() + segs := []int{1, 3} + + // Double check that the inputs have a broken 'shape' + // which is a SEI that comes late after a picture NAL + // that results in N/A timestamp and position values (AV_NOPTS_VALUE) + // this behavior comes from deep inside the ffmpeg h264 parser + // + // Frame 6 of each segment has N/A pts; frames 15-21 of skip_1 + // show non-monotonic PTS from B-frame reordering. + cmd := ` + cat <<- 'EOF' > expected-skip.out + ==> skip_1.begin <== + 12.844767,N/A,1156029,I + 12.878133,12.878133,1159032,B + 12.911500,12.911500,1162035,B + 12.944867,12.944867,1165038,B + 12.978233,12.978233,1168041,B + N/A,13.011600,1171044,P + 13.044967,13.044967,1174047,B + 13.078333,13.078333,1177050,B + + ==> skip_1.mid <== + 13.478733,13.311900,1213086,B + 13.311900,13.345267,1201074,P + 13.378633,13.378633,1204077,B + 13.412000,13.412000,1207080,B + 13.445367,13.445367,1210083,B + 13.645567,13.478733,1213086,B + 13.345267,13.512100,1216089,P + + ==> skip_1.end <== + 18.483733,18.450367,1660533,B + 18.517100,18.483733,1663536,B + 18.350267,N/A,1651524,P + + ==> skip_3.begin <== + 24.256167,N/A,2183055,I + 24.289533,24.289533,2186058,B + 24.322900,24.322900,2189061,B + 24.356267,24.356267,2192064,B + 24.389633,24.389633,2195067,B + N/A,24.423000,2198070,P + 24.456367,24.456367,2201073,B + 24.489733,24.489733,2204076,B + + ==> skip_3.mid <== + 24.723300,24.723300,2225097,B + 24.756667,24.756667,2228100,P + 24.790033,24.790033,2231103,B + 24.823400,24.823400,2234106,B + 24.856767,24.856767,2237109,B + 24.890133,24.890133,2240112,B + 24.923500,24.923500,2243115,P + + ==> skip_3.end <== + 29.861767,29.861767,2687559,B + 29.895133,29.895133,2690562,B + 29.928500,N/A,2693565,P + EOF + + for i in 1 3 + do + name="skip_$i" + ffprobe -loglevel warning -select_streams v:0 \ + -show_entries frame=pts_time,pkt_dts_time,best_effort_timestamp,pict_type \ + -of csv=p=0 "$1/../data/${name}.ts" | sed '/^$/d; s/,*$//g' > "$name.frames" + head -n 8 "$name.frames" > "$name.begin" + sed -n '15,21p' "$name.frames" > "$name.mid" + tail -n 3 "$name.frames" > "$name.end" + tail -n +1 "$name.begin" "$name.mid" "$name.end" >> skip.out + [ "$i" = 1 ] && printf '\n' >> skip.out + done + diff -u expected-skip.out skip.out + ` + require.True(t, run(cmd), "unable to verify input; ffmpeg behavior may have changed") + + passthrough := P240p30fps16x9 + passthrough.Framerate = 0 + + for _, i := range segs { + in := &TranscodeOptionsIn{ + Fname: fmt.Sprintf("../data/skip_%d.ts", i), + } + out := []TranscodeOptions{{ + Oname: "-", + Profile: P240p30fps16x9, + AudioEncoder: ComponentOptions{Name: "copy"}, + Muxer: ComponentOptions{Name: "null"}, + }, { + Oname: fmt.Sprintf("%s/out-%d-pass.ts", dir, i), + Profile: passthrough, + AudioEncoder: ComponentOptions{Name: "copy"}, + }} + res, err := tc.Transcode(in, out) + require.Nil(t, err) + assert := assert.New(t) + assert.Equal(171, res.Decoded.Frames) + assert.Equal(171, res.Encoded[1].Frames, "passthrough output frame count for segment %d", i) + if i == 0 { + assert.Equal(172, res.Encoded[1].Frames) // unclear why; ts rounding? + } else { + assert.Equal(171, res.Encoded[1].Frames) + } + } + cmd = ` + cat <<- 'EOF' > expected-out-pass.out + ==> out-1-pass.begin <== + 12.844767,12.778033,0.033367,K__ + 12.978233,12.811400,0.033367,___ + 12.911500,12.844767,0.033367,___ + 12.878133,12.878133,0.033367,___ + 12.944867,12.911500,0.033367,___ + 13.111700,12.944867,0.033367,___ + 13.044967,12.978233,0.033367,___ + 13.011600,13.011600,0.033367,___ + + ==> out-1-pass.end <== + 18.385033,18.385033,0.033367,___ + 18.450367,18.417000,0.033367,___ + 18.618600,18.451767,0.033367,___ + 18.551867,18.485133,0.033367,___ + 18.518500,18.518500,0.033367,___ + 18.585233,18.551867,0.033367,___ + 18.685333,18.585233,0.033367,___ + 18.651967,18.618600,0.033367,___ + + ==> out-3-pass.begin <== + 24.256167,24.189433,0.033367,K__ + 24.389633,24.222800,0.033367,___ + 24.322900,24.256167,0.033367,___ + 24.289533,24.289533,0.033367,___ + 24.356267,24.322900,0.033367,___ + 24.523100,24.356267,0.033367,___ + 24.456367,24.389633,0.033367,___ + 24.423000,24.423000,0.033367,___ + + ==> out-3-pass.end <== + 29.661567,29.628200,0.033367,___ + 29.828400,29.661567,0.033367,___ + 29.761667,29.694933,0.033367,___ + 29.728300,29.728300,0.033367,___ + 29.795033,29.761667,0.033367,___ + 29.928500,29.795033,0.033367,___ + 29.861767,29.828400,0.033367,___ + 29.895133,29.861767,0.033367,___ + EOF + + for i in 1 3 + do + ffprobe -loglevel warning -select_streams v:0 -show_entries packet=pts_time,dts_time,duration_time,flags -of csv=p=0 "out-$i-pass.ts" | sed '/^$/d; s/,*$//g' > "out-$i-pass.packets" + head -n 8 "out-$i-pass.packets" > "out-$i-pass.begin" + tail -n 8 "out-$i-pass.packets" > "out-$i-pass.end" + tail -n +1 "out-$i-pass.begin" "out-$i-pass.end" >> out-pass.out + [ "$i" = 1 ] && printf '\n' >> out-pass.out + done + diff -u expected-out-pass.out out-pass.out + ` + assert.True(t, run(cmd)) +} From d9612732bc9f437fe57bd73f39a7bfb52b3e67ac Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Sun, 8 Mar 2026 23:31:14 -0700 Subject: [PATCH 4/6] Additional NOPTS test case --- data/missing-sei-and-pes.ts | Bin 0 -> 20680 bytes ffmpeg/ffmpeg_test.go | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 data/missing-sei-and-pes.ts diff --git a/data/missing-sei-and-pes.ts b/data/missing-sei-and-pes.ts new file mode 100644 index 0000000000000000000000000000000000000000..c164aa0862e712dfda7ecdd80a28d2cd9c1ef7eb GIT binary patch literal 20680 zcmdSA1yE$q*5FyV)4034OXD=&Km(1tySux)ySp_sG}5@cySux)Pw{{6%}z{gY{VBa zU+gXwaVs-#$}dmlIrrqbiMSwusTlC_|A#*Sz!w1k(ggqjKrR78kDA-!asZ~h|If1n zKz&jE!QKK~0&qVskj^l*;@<#FP)NYJ0K7jZw289f+X>4I0Ex^)27mxRz%rQ6+aM-} z0GNs;p{OS&WB>r{^9~pQZBr0R0l=LuMg##@5Z!$k>sHg-IL)fXu{7&%(sQ4!mLN=xEE$$mrtY z!r*LXXk=rlXU$+^Z^HOF5`(Ful_ju_jjf}ZjkNI@M>tJZ)sy- z!N<(a#LYycXRT-H=3vCfsLbqM_{pmofWW=p^=`UyN$IG9}6=bGc%E~o`a)~t%HS`?PraDbz*0$V`FUWVC2Y0 z&qCyAY7cAy?2O3L#>PU=6qwQZzseje%?yA%|0!T1vbO(c3-1zyWP+jjVM{Y;1vd|5a!UY-(ZT z2JD}Y`9CQgD>G}Lj2#S&tc?tuKD+#9_)odo8#$N)^Y#Wh|8u^d?d%Qs4D{`Ztn`7x z{hXJ+84&k#hAhCkz=WfX<>#+NWcN8aAUhf#69We@Yx_CUKi_(;d>mZB0tZJUTRt`- zXMJZKD?Qi$*~w>K-^tj)%>7@T0@sI*De$Lv299cMW@%~kDOfXG;4cnbUBIE(fNRJO zxR^ed2H+nJ009M%eI|@T0|7`Mo!@^+J3p^-&&keL5P*1dM#ZkrWU|~!gSd>_@wqBM zCx*WwJ{}|saN~P%^_yBQunasai96InMU1-o&CD3of29cFpmt_Kyu zVIV?H=*rhGdUq5eEc3rwnL<&b6&jZVsazUWSXb1)-*BlBt)o1aU1EN-iEkc5fCA|} z(as!W2eM<#D-s*%zuI-CKe)vV-l-TuFIzw`y z(OR#5whP;UO!-Xda|>713|XEi@%9%~AUo!(HvG*OKkdoCB?X#j zsNjxG%4WTU<^9yn7GK-DajAZm$0-y*FX9s3F+&IYkCevbW{^NRV_og!Z7LW^=s*T= znwr7Gqbh8XRWJu83J38b`ywB=QXjDepyPa(86EN+(Iz* ztKFeILUi@2I2~v}p?u{okR1zF%FvZK!v>lH_r;P^I+z)ZPl?pO$j!1ND}l72p5Y3bBZj)~xhNp_*GJ_%Jq@U&5iSgEG{I+& zPF~sMsZHmkSFFw3GZ12!thSVa*C)#?^?6mE$|npJ=*1&Ia!#kr`cP$+`|8sI*|Djr zBAGa|(J}=5nuwLkRnGe6`qwL@5|^7gO$hY{9k-Q(j_?_~WT2qW@(WHq*;AQ5`u#}h z-o6_SvQWdok%z=$frIg*m$S!0JkBuIf`6|IZ>xvD_b(^CTPA7?ZbLNt3sDT2W@xsKMuY$N8l;Ey=Z0yJTQoa4&kS233Hm0Bg+H zPx#&JL*_$#29}csx&9$qb>}3MaIbVeLto0sm7F1~)($?{XhyhDzd8U3ZVGXgJxH_= zr+R|mLa*!j?F)A`2}ukZ+@V>2m`e(;#U4G4(Bk-Wt+i>Gm-py}QlW8&Pf6LM?WpT; zn89B=jWN*g#?fuV+-<8*zu)i@a1PLSWG1)+h)MNxF?PVN=Ig|t%Xu1SgGbMxK_>cT z9cDG5w9bI+xFRff$$Lu0rktd>88|kw;8ez1?V}F?%Z%a@93M{SAljg!NB%Vug=%hI z52}SaEN-9&#DIgA@&*u;%m<)*aDcGrN zIcYXMNPum78|v-^uf~`sB)9J)T4gUZp&;X+f0WUT@v<`lVn(}X^jGHb?53IQ8_IJi znjC8r*wzLq^AN`KRFEv+IGtZqqg2%U8Fw-ci@zgv2mc@v(gm{P{qPu0+)0+mO#MsC zFf1RI{Hw#%ihs@z5PElvTC(K?{iEmxj#*)6x6Rq_IIQ?ijEIjfSny|&{KSjv`L;d9 zhcT(9PZ2&wKzwVAO$;{IoPTg;yfY={j5J++OZr z=SSAC+Fvp1T8N2r-2G&}*F(+czl!;yjN*2nA3Me$RcvMi46~sCO~H$!mw85IV;(D+ z;P}!9^EK>zB#W~tbV1J3Pr2hO@cyh_puPA>wZagR2x}NtLVD4C76+2qmRcF&qqt6I z&a5SjhHv0z*f7w7^xei4W~d)-c?~`p4E+mNTga$WEZJ zTdXLTMt<4@PYI8CIa|HNEj;a3Awj&Khy(paB zRe)^Qpah5A52N@)PG}#3*s&Jkp!Mq^8R%}MBS`kl^SKB$w=cYob>vb=MybqyO~qUi zOAouJlzT8!VPOuNuR*D{84L8FQ-r`<_3m4M?1a64AJmTFoUSOowxEI{o(3DMX&KH3 z4SkW$E@3|S86uF2>=R}d&V_B*53!ua#l+TSrb%P*eRctLN13?>OdU0IxZp2*(oD#_ z4sR6K#XUz43B)e60&!-|7f-o_#Jr!q>IoUtU%A8wW$~)q+Zsvb2(`d*bvjPohUI0l z341Fg(BAP~&E9tY>Z{2Yd#9a{$WW-e%-=GI(!h?51t&ht+WQc|#7i+i^&V;Md-$a| z=YZKWn9Gy_*^%H3sQj;pW@23fm-}7e%bTl}P7y!WoklRQ558Hq$i$+BWq^7g1>*Ao zu7dJr8CPPDgwimO+lJJzB)0}3WBGn7+>_xv880hDdCiM$eSiWk???a<Z*$oei zfF@zlsvPQ`!c7QWuuwncd%;f|Ys!ofm*!E3{uTFdK?{xFU%)3JJG?*S#X{7IkNH$ZXh@YD1BK0wtZK?uv zlJXB+?TsAl+t`Wp6aQ!qE(6M zjg~Bow(y@n`FqiOC}1D%!=t4|XcpY(>4m-&bnTthqCQea$x4{YAC65BcF4!EQTn*> z6#DW^8xzBNshShg*wgC3O$=m5G1hU}AX8W8bT-3J7RfEfJsU|JaWI=LB69@65tyA6 zVN2I}n`7}hpmUOa{As4K3{iwX68DD}vc~O~kH}rLAKsVQUl8EEP27>AgWC?QH&@x0kH{JR%_bN^Uqpi3X(PpYT@+`)>b3Qy{@f0mtyQrfx(%_0s z&UE!BW^gYYy)gJQ&JntfLJ-F}WT(?B$&RTl&oi~{yHHe`KbD&OAbPBeR_N>Kd4TMw zrs@9W0Yj~RT&HU_q~SHx@f6+)@>fl`Sf@w2@f3>KSXpVc{VztZ?TudN&qxsDluLo~ z$ZZWr&!m%XZ7cL>?R0@QA6Bg?W3FM|Ee(=I=b@#M4Yp6ivm5jnQ_OC5_YF|}^EWf^ zHd?uvd^eQmn;xADzL7_URU0%1vX|NUfNEKdEf6fCh{pS+M@(!mRO7m zBVx$CJJ}{U=H4Txqcx#QnNl7FvBk$+fb3}6hZ}^_s(2X+&SnE@r&<`xySk0_FD8hXrKAG?8$&0GLqAj%4p&h!jaB`yBlOKCyu~L0|J@-*W6ql z!n?dQ;x~k<*~}KXI2C~en@cZCp>LSat9JN-2CFRMZ-1tV9!K95zN_Es9uj?>VHj7H z_QaRfkemzkm&}nfSGBQ z?8%CMU{XBVHlf`;N4t&4s7koCJ_$VpWh<5(inv|UmfTwsglk>Gx85`Huj z-h{gUZW5}3ji%ljTuNShf|Cw=lXuf2xj?MxklmK7VB4%RX{^k5E$dM)wl~h}3{=$> z^(Ydvk6iLv@VQGv;EM3_rpp4dV@5Dou~Hhe8m8lP#Mu4#%FQ+S)rQO&-)Zt`Z@9Q( zj|qfPsiCb)U7#Gsq_LpTcQkum?SAS!NN1OL({m6dgfMvzY+L}(g^Xv8I+)ig=Aq2w8#Ikvt1b0>nJ>XXcZEz(sM!0EWICK-F zg#kRp@sE!`OXNYQ;FOP>)1j#{8pLYtvr0Up>Y+>Tn5;JV*4+M=~EaV}hU2Mir!zcH$b$vi~?0uT*fWF7Xc$t2CUnLLG{HhmK zs%t-)K!H$kSfQ!$Ewc_Zc(uYE6}18WI^v!5(C;A%Y}ezN@pMad^RSY{7P3w;F!mZ<@FQ)nqoeUdlk|X z2AL1BdA#ywIs@6cz5m~h>A5g={7L~E%{5Gxm3f1%=0(L@4U!ZPQE{X$5=2+HR#FCY z`Mq(P7S@cL{8JW-9p`v9rY7tA7pEs+OpoU?riYyPub3XR%s(+bFr7GHOi%1502qjX zVfqZrfq;UGe1;{yrITU>e}sCwSf_izp;QX?8N24EL)iT=C8O9>@5oH0P>yHI9BV<3 z2R}0YTht!io6>5T^R0~2b$iS>vf`n)^kDS&^s@+|6X{1f{VR79ehGucYbKmXR>l{< z1BRCZQ>!M3bsy@{)goM1)Dm!BTE_nP1>SMq{bJLw`!=jD9=tL&s1GZHwiX(o-0?b4 zM`CC|Snc2*$^LLY`^bBIkg0p3JrCQ)iUyX|AZ`qOWFJX-R`F>79_<1l>5<*^mIZn+HM zx&p2>>Xg{x;N$w=CL7rFQ;dxjOUrR+h~{KKcKon&CCVydgAzRHtliiy>U_=UllmHS zUR=!~-a@H+d+zB*p2p^;w;?9J=K%&P{%2UBNBrcx&JkL2VVnyo!)BwV57F;QzII)v zG=;UgY`V;B{>3=Xl&;Dt@>^FP>rms~UeCg0L184~GE`k?)EF}C9&x`cr9smD$seeUzF<0bqW zC0!$kYBHopZUv6O0r}z#(lJa5f%fA0>%|#deRfgX0bwT+ppw+f5*zo0-r!e@sMod` z)E4M%+~k0Xy*PN~Nf+b}SBZxmwBOq3U&&-Bqup(biV=M@(@$L`guXVp-E#R;acRai zZuB?u1ua#4pbR>+rTGs^1eGLpdkoBLQ^a?3@(vpB?j0_(Z-tgeU$*hFNi=E5?kiom zm)Ihno9{Y_)iaFzE%${~{q-!Y8mI!xHDIkDo_~z|9-Ep~NBI_Z?3B#OU~@6XJv7F2 zN$0ZusPS}Qpkg3L_4?xa$2vRZ@{gpY@MhwzYa<)w)DpUVy_hxxP5~j5*7xnfNXgjR z(5vT?!oJHJR=|u{c~cd3nCCMn9b@AR zlciZW+r5_C{9O~(YhDc^w9!&Ej@x6C(Y2yHlb(?nF1}QMJ&SP{;pp9U+0`AdgW&Ed z=-pTP_!>k5j#?|9shyKH#fQc3Q`+CC0?(;Cwn42_(%!l|)yi-~=c@-8sa@`x_W`ma z+q!jJ92~la;|7%`lEJs#g-X_lZ-giGsK+ ztQFq5Dg_U~!KeN~?@ANkt^^|)UNx=t!Io<)VGUa=wiWR~fB9HlPAH)tiCj62;XAp9 zuIt=7o@Io!)1pz7mm-7iVuT4Z7V20aRM~XYyE`1+R(#NNv@0u!)VmI*9@9BRWBW(B zll@iK@4-M&H-i@=&s(*Jb={fO=e3BR+VX~@AIOff@3Fow(Pt9wvhnK1I00@=94UQP z@nP)~vbuxDFj=aT&A;#x$alU$w@aCYU5mIzPG8EeA}n_rFZLk=I1o|xGw0Tbv{?tI z_nprPC(ut)IicgaELI>cz_iRkZ- zqJyN@Rv&Qz*-5k=?djU2TyXZ=)EPZ2JyO&P!ibeUcWA$>1TX(U+03x_yhQD>#(diyr zWF@)e2u3%wmz`;bl2$3GTt6T);m`3xp%vrC#gmA5MMq_a1of&UXyg)qP~LszfM|8s zv+l{-XZj6}(!qBj!x(2>$mSpBTQoCuAPAc)@D;#FD1G+MNmTm>|-sDxiXVj)O zLg*#JS=$O)DU;frvOvxEp$dW>*5r(gc-X?^f-GX;n`^?pxpRl~enXO#!RGzJWeQ~H z5dJ^l&$NaXItR^BOX8oM`}qH>%qT>r>^?%|jWKKeXbOsgx$)%k?%>qPfmyjucwQ zdY)uo8uv&{pbSQ!Xfl>++z#|-I6wUvQmTLXGhi40dPg~Z1@vbk^ZxN?p~LGhLgNuLAra|M5l>7W{A{9+XbyMpktJHewQdWL-xPNil40q98iIR^EXa9FGd_lx9 zp0)$eSzXGHZ0i?&SE+UeAUpP|R1vf2Eh3B9=##DBwh4!WiWdbU8Kw4Whl4sMOeQ^! zsPIxK8ebRpu@8P*Z=%gdXY2Tm3Ui=qtal3)1^K$pwYQ5v&>P<4cl^;}*DV1)RliY4*g*pH zU~*O*9&URkb?gBsl}>z9BzHEP6}&IHyY3;wt1{zL2~HHYXfsT3)U;o*K-FiQ0LPxv zTEpK#yMOf=P|Oe-ON@jQ(68e>5~=)I(i28I*x5mx7lwvDg(H3(smA6mtC z)5Ja#C*C5igjO{DHd~4{rNjk7NzItE)&!Sb7y9bP#O|k83~b$Lu}o7`WEt{sk8xEo zY`ce591ew&t@~Lx@l5o72(3{s!x&}eV94pKRaet%y?bppYR<(6Z*4_i(+;-n7hgu> z%5fLZA}~7q{uf8ODgE`QGHQB)UlUq%Nl-v`+@-RRy2m>o1)VQQw79c24lKX$SEVOs zr*tJxbIlHtpa8#%V#-y1iD)mNAQtdm$*7nn{v35B2(?&e3^Izndr)cw7mqn1G(zR> zvFOvqyU;A2l=u-~Th}uwD0osQZ0OJk;>2@02vFXsF>!=nUzXzz+nF%Z8%gx1_s4`$ zi#3CsFhvsOQwA4xsrw^qQWdu1r*HN)u+g>5s0k|+dj{UM>Ss~nw>oL>vX0I!Bx4Qf zC-;*X${fhfEB}AM&iro&L9h&p%ry%03l{=4zzwOG6b)nWv(NCi(gFl4+|7A#10AMP zrxlr@5O&(fcxLWAL8bQm6|5XKoF<0;{3X94 z@$;+^Hx2Rm!>gv@6y*kJXZSwt3|s5J?Tp61_D$H|fOcm3>VLH}@c-BukZgUCk4F-u zTzKhWjG%|;^P2J+;~|NaKIt932ku}VSH#_q1pN4Fl>RB5N)v8;B*_Dr75*5%@h&ad z_eQ%z#(pY8Y-&05hQLysAC}J5iF<>P2TdT*Fs_YGaG=w15q|5}>pckveobNia2eiJ z$n!(^@u8|zX|u0=3pyUn=YTr{cBW|xDA6gP+zIq9nmLD&SGZY&QMzH11PsClbla9p zu+JgeD;t0Amr6eq;8+AaBg(gIbgLEzY7sv5qI_IOP%YqhtwZ_^@Pm&EmSq=Ih@dmz8Z51Qdxz@#9%gwjLF84MC5`; z#m+&|)56fX+M|-Ok8#*9)ee3K{ZKBWF66Y!wbVQqx;rI1^{@=@rIX_xYW3!A{JzG^ zF`cg8nbQnpC#<(_g_oLCrPX@{kZis>nO6dQ#QW+!4jSMH(tsM86b<#> z@H!m@UVjM8?nfF&OOY}W?{V?7f$F#m@og^+Ngin7y<(AQhwg^O`x*8?gcXDt!8}cp zFI>ljR<|4SHq6GDYWu?}p`ylE5quHJk|th31M+!LWYDm5^E2k}#t01yI##Q_jPz=H z;bnnny%Bqxt zQ7A})2hL>J@``DV`g$%GRi$1WaHAyy*snHops#Zgy7VU>h%~0iz2?^+0<}85Hy)pb z;ql(IbUqB^?f8NPK&Rg3cAY0@T9GuJhTna@R_KpyYS*%EkO5$b@rK_*5|%SNx#^ZL zu_{!tvhMiQD#o#duE<4Mfb7f+|217Ts+{JbSyH!jNXo@y!aagF*SEhSdxi(TnP}dO zbUs4&t+aT=MqT3F_sFYZROy`X1&L6k4;rraUWLP%mX<>iPuI5vs@oKe#49K(k z?S232j@?2t{=qb4{$ZQJxf~2$7jB$!voa04@d%z@IIdn<+tdWHC(P8!bW=uVwauz^ z0{2#=6R4{!pSmh0_b*+AvI9Q&|ItvUe*zlDY|=sk4l-ObYOs$7%B&u2ip4(KQP|&i zEX3>qYSDg@PZ_~D_9#xz?3(P2cuT~sij^F(k|1H|!KA3kTsl2Ai#fk@jU}wLSo{ko zJ{mznTJko7N`IMqHfF(UphipAo50W;c=ZjuGek(NB_wS*aW=KCQBlhj;1Z_{%(U_w zt8lb0`hMA3JIBg{nqn5&O?G^fLu=3wwmVb=${kg~`5e16rdgS2#ZwYzPfrkn)a&h$ z3@uX&>JP18;htiFLq3Kw+TLRTip@)>&&Sy9x4eFqdwPEU1j44#whg|*q-^a)qOHmf z5knXG3b@mnJ(jxMn8i>fu9<3337!TOwtZnoa|#jh4tRGtv%=Aij17}PBlR}YHY@(;orfhvlB*JyIYb?GGyZ-L@Wmux{xty9-kwp<&nbj-L&o~$=u|dY z(0mANYrej`m=t=tm!$^JguHjq*gw}Jxfz0-2{OwX3)1bZd7dJY5p-7;Fq(p_|Lk?z z`x-oTYQ{y%3S`H&%C+40ehcp1{9)m>owGACfR0DVhk>D10BWzxUgP65=;|-kf5mbY z<}wK&*pKNZ^CgkS;&O?t6y~PVE@fO`1C<$N%Nm>v7Qhejpu8P6LY+fBGvULbTk4!` zc%i#mWC(mMy|@w*+*lt3UFzc%{muUksm+j$cQbZ0G~6U6<5r8D#0wWn;)A4Nm%8w z?^yp1K4wzaxeI5PZ?JhiVCqaNs{d-C#A3iiVS4H9PK4V*&K~X~kAU8DkM0{06n~{i z0szasy!WD|mtFg7>s=jnp|autuT>UooXOO#2_A-=fPGjYE2U8IX9HbU#40yDmxLer zq$;aj@#jfbLf+b1x>ko9lEWg99oG^We5uv(a*IURSijXwOa3L`r@-Dws27*MjE_u< z)*zx4$)2+q5#!RBpk{ zV7#Ylm1=!NEs=mTzh2-(!%*hqC*2>_Ki1;x7cR1HdP68G2-C%JDK#B zN1fwa3E3Q)ShSV)s%5)^!jnZ0vg@@YMcej0I|aSn?9{nX67EHd_NY8MVKegK>l;9J zUeo`YPw>CF>i>l-2RvpmdUUy~?_^(y5Q1XfvSQ;9sr^`2j{Nq#nIg>+e5SkO7DLE@!wT;|Lwn@|Hmhg z`}7I214WVPKKAvU!hW(I99*QrJTjb!xXP|v5w`rD#G-**8?ltYT-9zK2$27>DOhC} zsAH00R<7KMypVemOPmV|>p5@}BWpB9Vx99b^foqx__2um5qO)Y+(oqC_KRw$E0D_B z?;(Rf`3G9mzSY#%NLk*#Lgi|t12zYYusM}&|61=AB zOFfajPd}0%N846HorDZC*Z=Rh%!FXU2vP{*4H`5*tfFA#chBh^4O*;{Zy#N#%fwiN z>?_fYO=T|7s2D9vS<$4YiGsaVnMkC{gt9@8*F`?{cAU@wJgUx3>(jEwj zss!C@0i2P5gN>(;#%Fa!Wnt8cT9aZwlS#t7V9*h>7*qm3V-k;th9<`_xYS*u1Um;% z{td#iQ!Y+j#WRh&HXXb@MdPn@Zq`{e(umcd<)etkVp-UpKz70*@(pr5WjX1Vzsg{s zBFBkx(Nye|5Z2e3Q3mwb@5G1>;&K}c8wlit2Qa!sk+hEtSL%^YNJT7pzDqEn7DTp1 z)-=1pR&+*RR)~!6G=Dvw0m@fpvc@A?Jqba3{)C#pJy?DXYx;cefy|pM z4lN~FH+v+>nEI=8LZ*)-E=XrNY`TYXW4y{b)g$``Imm0)y#%i&RKhd!f6YfhR+Pm%oGW;#Nep+-p%Y zssI+0kq3JcMZ)9i=bMR39@d11!mqfU7RJI2fzu_!M;r*1xi=%oCl8k4o3a%=LiLN0 zw}G2GNfQoQRBiSw5Y4^jO9r4}1?GzUJ{3TA7KQ%?J9DTX-Zv98Y8!Hp8)`GABonKc zmnmcn5zRIhXPu9niedQoOFRSuL12*kU_!Fw_vu*=m^`4JVg1~Dn7RJj&IJCib_OK7NaSN* zO=-)jdT8>CC*7G2n#w?ZgO`55Pt%@&w!GRI&27k)+>Z`yI5Tn?uNIfi25J3MwXwVL zmOsQB=x|ISa)kOyteMf2FCFK$c%#LXjooF*=}?c)k<85Wm8-`>7fP#xK)0J*_+Gh( z{2gtX{#)B~L5B@RVxTpLN+f;n!3%&c*!{SAzYtr1jYR=FK|RnrmMkE@mplv!D0j5V zo=67|zv8Tufo8qI>4?4yKAed1c8q`$tJ9*XT(b2{z?>K7?WtX3Q*J zQ)F{uyy(iYa(WLInL5x+`(j5f=530znsyW*J39X9j{0aO1@hUuSpjnb@N8JtvDSSZ zUH{h;P6<4zuk(z3$DYgzxr{WZ_ATz)L&UqkCf2pvP5hgi0fbWk|DI6kn26y)?8d-L zWhb2*B23>wJS~|EaRDw}c{mJsQ*A+bh*J=8Qwkd`>DLLbZ9H~tyoyvGFf{icF>%z* z5F8a!-_N-sa<$%k3fy<~2yN^qSt9wRo{8pofbzNIf`i{`1fY_u0@{c74mz<8mz zfR|U@Qaru+X4{jm0bna0M0L9*v4EJt%>qJQ69>xHr@NZYc5;T4{MeIvlOh8QsG3VKyEECQU&`pfr%36XG9Tjl8V$WmFoL zAmrCGb~(LaZlVp&(9N5$i)bVHKq3;Xfk^d$eO$WmRMuNyaj3O*^b6mjo1Tpd6NSWa zU|yQ1M&K;^ZM^s{vbRzwMcdsY9>fj^y*T^$XvC)WS#$`~VE(aI{McqHoM%~R5l;P;6n7%c2xphVfFVkm*H^EP-#|D*yYNd788EkQV*z z@{&%II0f#EtYDq^nfMk^SHFJhst5ePbQLoAKf22FPn@9aGfwdIL-r+!OkrWk>qqJ= z7YybqGXE47Q!uV?X+sH?b6zC5L zs)rT1FQM+}&PoD(#T#;p0u~!X1KBK6ywcAZdllwAsyJ*ONjY^b9z894n`jx7apWxm zp?sNk>SM2V(LRi9-%t9@S4=PF-tU6IRTJsA7K%$CaQ`{~PUoMLn3=moEzo|OIHumh z_ILhhDlF$}i8a9&TG~VaYID=f4D=7?yIbh*V=@l3_|4@%)FiHk#_0LHE7)d`Zj^(Q z5;vIjR@qf%vw02|=-hJQ3*mvR#v#V?UeJ;H6jKYZv~J|I*YbF!sm& zfRrJ>1C}@4m`lR6(4W4^?wyA{mhJFhxPbO2y^ja0_=n9&#nV=o0b6-Z^s~8igIB<^ zr>~3j1lX?3NQH=7Ek1C~;N(JmEAA1dIf(yg+;x66Pls58JxnrW>>c9xsT5t@N5nzw z8{2dnqG8ZlN{+rmi?3B}t0DO#mBj7Xj>ppr#pJS6pHyAeSZyU~7v22e^jDEnr{ETn z#HeCe?EKP5R)_TOjSj|4WbfxbogJ4y*H=fY+*M@&z`c)H6Lt%~I#PW(Y)&u9Tq72I zvUMfLc(%TaUVD-8kOGu1oXhs5xRXGmJb2v3?|VLP6H1T`9+4^>=py(9o$L)R`c$SsPcc{RK_ zv*aF#LIXlMcCu-cv)EmUki$1W*zvZh9QPqNbQ#Y?YR1@oRDA%bo#*sO0c9rJZsV_{ zL`Rght}lyS)3+GCHw}52R>YI+$c5Ry-#2b~?U6Tjb5s#tMVjnJm4_~;twy+LkIa!x zN?gg590LyZ#q?Xt$IDAM?f~u%aE0OGBtUjNF;qVn;x6+V0L?_)reJcOQpZo8xf@M_ z>mc8shB;y_@`-M9VfI#y+;cgU2fthjWMc{-t|p#^b5Uqz<*7?dVo(-kC1|Zrujj+5 zCbv6gCb#4v7x{dfFEnZ;{eI}+S`LLtF4iAa5TugY{!6QV{&33g{~*0tj3qf#0dfMIkmt#_b~ z0_Cw)1Z2m5rj3esa|r7HEd%0bSb%O6u(OdF*3vPUPtS$Z(#JPy#b3C0tRC$6p1Ht_ ziB-Ga{9M`>{_{q`3hJPVDVfFmb!>AnLzNS*g~MIfSYJRCMatF13g!bwg1wT##*iS&UOE}*CrsEUZ3hxmK!0^UD&)| zCNKHe>fb?kmk7T>{g|^#yKs);oc($%ZJ(x}tO`z$@~bI(4ZNJ3R|5`K6dk+!?gQ7F%`u>siitwy3!;0sfD1SyN_{5xWC6Bc+|YVePE?m}fe1i@(c#1QTT6A2SfPql!D(N=S-OPN9 zI!C4U4*B;y(USe}1*&h@QlgHyf8SZ`-FbyYCm9gGU3)0X_v`nxpS{?5m*$fZ9MIhb zS%@Dc_0kM}3C=R@Ze#eBl+fr!J8;URH~x_KrMlUCrg+4HRSQLdYr*lx+V{d59bD6! z$`@_;tEJod73h^lT2v3Q$Vo4!u@GP25Qgd1nh2%v>@)Ert$TN0EAM8F&~=?T#+D zREexB@Lko2vFh)8C#bP5bN=B@ns3}_apqqAH>td!KV+fhseJ``U*1NFBdru`h6fY| z5q-Ma|K^k=_SMwUEp;1AZ~4dZw*Q3RMMddjBl%&}h}2s(g4Z{;@olfc9Qj}V zj_DlTLf0Q$8`7rD&-KJdmr@5&%tr;q^#%TVZ06Y&L~j zQmNXZk@~Qm?+@(RTZ^W}{c6O4zuM+4!}LW~aWBT8c?fu^~Fc471k7;nro#g&?(bj3cSElC0d~i2MJGh z7)sv;Gk#*KS;7sXqz)@&I0mw#iA^+Q?!n`KKUB6>Uru@#24_m(hzQ52HLJ)duCH_z8i8@WG5hxD2Th)J4D zd*r6p)N26}cybd?t|9@K;A$Wl?DHw1--J}rSVUeRI5Y(XQPu!HJ(LjTHwfMRkwA9z z$V+i=NHuUumaR1MUTM@5Vl$8~%Qo=2$f@J1*y1O$znfpH8z(3Y>cTXbjuKz0mSRbxY_fhRT?27;)qrG!+O+$vT=~NEqupG(#fg2KqGAT4kK6OI-Mh3`R5%4)h{#f3xQ@QS?Nk zpgG`<`#VQ15ivT=Mn2qQd}6;9RMk);$lh!$#t4eoag`|s;@(|K>tRB#ss2D3shC=J zyFNI9+aqd0_~f$9Z&67{>YigZ$UfF~pFvvUXID3`D%2y>$Kqs!HRKz6XKmL0* z+rRD1=)det2QkpjxK91Ac82U9I|Gt^DAEz>G{Wgat=o-(i9w9>R4(WmzDmfcz;O}uHp6WngMWYu5%P@p(2|1lVx-d;_?y!TrfwbGEi#lqdEZmev@6L0x~8RL6)w6TBH4MnWwI-V%odok1L~s!)@Ec`6?Ig!{aP zKtObMDX+;?ZgP}md&T+FHnZANOS-;%s4k{4i=9FfjTgfA^SO#665J=Oy>G4xiyJE7 zh67dfD)7o~LWQCQr#q?cw~MRS^sKh*0($1=4RZOkN$uA;oP9B!Tez4$PRuoj51E%$ zcA9R`xV5n73@^DBb;)ggeG|0LLbjTHEfW`{Cnb$KR9e>gmyrV_(>9`+|0MqRo>!Q) zfP_-*=qr@yeWL{teh`zX25smrf+rch3g7AgdkcsCS4PeG(2MYhz=JqrG!D~4|F_q5 z2#Dh3TarYsBoN?cWa6Kg9`0KsB5u4U@)t-Z$v+}+PiS^!vqPF8{mbh=md^POUsAFs zb`}s-Js*+RpqDKN$W8(^V`>@Bnco`Rvjzkx62 zS9j`{wZR~39PKwPubwFoooL@FwEYa|^lF3Ej*9U_K7s=Ox&^}c+8;B^6!rN({&-^R zz+)qftfVlK5{!8Jop^)9_Se};sJ4|0akI)21!jQUZxUSu+_uP^81ZjoQ3_#}qW3lP zrb0WKOC=3`91`1bFevO%94{G((tcd z+}RoYy8I5yFs9RIg)~&&jP<2Ksz}M`m$H$+7ic2xeK>o8qD8k~_I6Jz!OE^|*iXkg zbbhMy)OU=9h7@mPDBjCiwId8<*6vd32F6c6lF#YC-_HEs_$({Q2_eNF!3iP`dFE&I zbaG5rDOM158n`FlHsg_l^N_=H0G3hMSqBt<;!>k(Ui zo+se{j1yE1{yR>P@QaNB$e7QjU772hPbtqBPs$jT#!qT>ahNRYiJ&~ zxs7<-B6M>ih;Ow|DH6#eCj#Y8STRSw92)~kiE*3YDX?n7xr@)B_xv)`Xh2SF41Oua z|J6aIf$hHAk<>Nr%WFv}WH7WxEW0amWRZ~hf_y!?+W#o#OrxP-xHxX?*~c1V$-eK| z_d;Q;CEFM=jltMchSb<4kDW1PU$T@XYiMi7K z&-vfwf37|7*z23|jI{EWG5LYuCrdtYds?LUmE%s(8qZKM>XJ}rfeB;4{`7}jT{{{n z2^(+a(Uw=4WH9KLB3Rgb+8O8GBF)8F7-$ z8={g4tyJ;_GtSA=L>e07k9ZJ49|cE!j~3KgDX`|=N(tj02}#QJM&qH z!@&(ZTe2MgmFp4p(l4L`SeUS?tAXpA+G6N^T1sU78S6%4(5A;s|E)bTWUBPs-Ud7N zSMc*AnFslY5d=fyL)=R|*BZP%{);pVeI`MCU)cOX7i9!0)M%TUDmfKPkZ?vTM{8_D zi94gVFY&o`r4nS;Cr6@79!~_J7*DzG$czN)O|Xw@hb>Y86J!rqK_y+*B1j7AL_1ut z1L;SvYyWJx&2e2JA(=BQF4E$Ajw|pj!$O~h(k6Ug<7Z0rt}TIAlGKqsC6Tfl%N5pn zN%1lL-4>g0n8Ltf_4AHx#C3<^Win8Hv3jH#4M`f^+&i!R3M;~pX9c1I2W@*e!e%JzpFl40QTf#gNMtJD0T$+F1u-7 z?B)}$IY-f~Gp`$;0%$3@iPQ!lLyw)9>)X`FZTYckTM6zLKRh6C;KMHV4wT35$)(v@ z7uET64vOz&w<<_0)1`}e7kC^4X}7w~y^C?gafm@7gA78l^1~&(!ox&lIphSHyc`g6 zHS08B8~O7Kb~d(%q!G=ZdBxuzI-ZX8_p?rI+pW3CMj15gRDtPvRJbXbILvJqX%>w~ zwDe5YB3H!#uNC%`nuGyPQv_!Ydh)df3yuS+zrWC7A zZ|d@qTWW@Hn01J@T+_(3w|1^VUrz;#em4MpK#>TW^Bws5yBQRslkXCFRDjq~av$dOqn;4E3Y&Co&Pw>Xmdy!CzyoIO z-i~?H=U)?~!Bz6!R8oN{y%U2Ed7PlBLTRt~)8C0>_EGo%u<&F$vnravYw%dhf;E^H z;6BP=>x08Qx_qZ`73A~B+sTn?uN%P~l*L}L9!E)Wvtg3E(iyaid-K!|PoAb_FfhiI z>{)XLg3~ebx=`e)CzX~!QeNB*l(rJI^pwk;Z<3Jo=8td<>L;2L*ntqu+*REo!@{L^ z%KWVwZXe4*OfV?M<%Q%n!O*rddpN>z$53Q#hBZ z`-2u9b$cXTwD48W%R#Xy9QNk0pEi`4WmewvEzD?FoSeQSf)gc6SO+TI>Aj(C`RE$% zL-lF89ko1NI_#h_R4oNUJF~H3f3IFNT2;1Nw>AL5Y4dHIpxbkY^~2lID%7RTyM~iy z`BwPsqtY#{eQq?|9$ETAzUE|vL1KoNet5AH5omS#F`hWye;aW6~JVh<|uGXn} zZ79X*9oGq^Qz#2*?h>d}mBD~zqEqtR1-n|(|3z2-ht&QJ8E@vXf2~V$92*(nUMoBK zF*>Zmm!WudRp!>fxrKYD@cDrfYuP$4<)54UfA{ZQkiWW0-uqWq&ANbOG*kiq?%!qp z_V4+0hUXYs;Vd=2uqJu){#rQ0O@DkZaBVvED_LS z>ThA@fpdFK6>A2FnXma+W4yXyb2Pl4G@32gx-iIV=?~3bKC@J&GEZ|0`31H4Weu1c zwuz6aF?BNu->I&T1D!pT&zNr?P7-jA<;x|AFXEQ7P5ST`dFQ^}Br>rqU2dhqErc&t zN1X7ZQI%<3=~T>O+w%n4y%@%(!vQ;)8R%4u_!{g!4^|*4;+>$oKGFUzMd=CuqJJB@tx+eUC zPpP??+wanOeQ z!>I;rC3NL$M}-MG5m8Kt8K2FPCTFvLII^vufsRT{Wgi`Fv9)PT-9S9OVArDmSG_;! zWrxA-nYi;b!?7I0&r-U@O6XI5i+pYfllyqq+MWc?zDSQ8DS8t9J$dWk;h-I%il&4cPcprD0DMToc>ZXc^H0 zMpsLjHf@;8PLb1vwh#+>qh_{e#XOuHlfd;`15sM;=svr4(NVpiyg+X@5Bdq~^~0P< SM~5!v#@AY5lhgXT1^)p@!rsCF literal 0 HcmV?d00001 diff --git a/ffmpeg/ffmpeg_test.go b/ffmpeg/ffmpeg_test.go index 982044b9b2..9ff502eaca 100644 --- a/ffmpeg/ffmpeg_test.go +++ b/ffmpeg/ffmpeg_test.go @@ -2916,3 +2916,74 @@ func TestTranscoder_NOPTS_SkipSegment(t *testing.T) { ` assert.True(t, run(cmd)) } + +func TestTranscoder_NOPTS_MissingSEIAndPES(t *testing.T) { + // This test case is constructed such that the H.264 parser can't produce + // timestamps (missing SEI timing information) but ensure that LPMS can + // still derive produce timestamps for that. + run, dir := setupTest(t) + defer os.RemoveAll(dir) + + cmd := ` + cat <<- 'EOF' > expected-input.out + 0.200000,0.000000,0.100000,K__ + 0.500000,0.100000,0.100000,___ + 0.300000,0.200000,0.100000,___ + 0.400000,0.300000,0.100000,___ + N/A,N/A,0.100000,___ + N/A,N/A,0.100000,___ + N/A,N/A,0.100000,___ + N/A,N/A,0.100000,___ + 0.900000,0.800000,0.100000,___ + 1.000000,0.900000,0.100000,___ + 1.300000,1.000000,0.100000,___ + 1.200000,1.100000,0.100000,___ + EOF + + ffprobe -loglevel warning -select_streams v:0 \ + -show_entries packet=pts_time,dts_time,duration_time,flags -of csv=p=0 \ + "$1/../data/missing-sei-and-pes.ts" | sed '/^$/d; s/,*$//g' > input.out + diff -u expected-input.out input.out + ` + require.True(t, run(cmd), "unable to verify input; ffmpeg behavior may have changed") + + passthrough := P240p30fps16x9 + passthrough.Framerate = 0 + + in := &TranscodeOptionsIn{ + Fname: "../data/missing-sei-and-pes.ts", + } + out := []TranscodeOptions{{ + Oname: fmt.Sprintf("%s/out.ts", dir), + Profile: passthrough, + AudioEncoder: ComponentOptions{Name: "drop"}, + }} + res, err := Transcode3(in, out) + require.Nil(t, err) + assert := assert.New(t) + assert.Equal(12, res.Decoded.Frames) + assert.Equal(12, res.Encoded[0].Frames) + + cmd = ` + cat <<- 'EOF' > expected-output.out + 0.200000,0.000000,0.100000,K__ + 0.600000,0.100000,0.100000,___ + 0.400000,0.200000,0.100000,___ + 0.300000,0.300000,0.100000,___ + 0.500000,0.400000,0.100000,___ + 1.000000,0.500000,0.100000,___ + 0.800000,0.600000,0.100000,___ + 0.700000,0.700000,0.100000,___ + 0.900000,0.800000,0.100000,___ + 1.300000,0.900000,0.100000,___ + 1.100000,1.000000,0.100000,___ + 1.200000,1.100000,0.100000,___ + EOF + + ffprobe -loglevel warning -select_streams v:0 \ + -show_entries packet=pts_time,dts_time,duration_time,flags -of csv=p=0 \ + out.ts | sed '/^$/d; s/,*$//g' > output.out + diff -u expected-output.out output.out + ` + require.True(t, run(cmd), "Unable to verify output, LPMS behavior may have changed") +} From bdff08d755e1983f51fa989b5950ce73769fff49 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 6 Mar 2026 00:13:10 -0800 Subject: [PATCH 5/6] Clamp timestamps going into muxer and encoder --- ffmpeg/encoder.c | 18 ++++++++++++++++-- ffmpeg/filter.h | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ffmpeg/encoder.c b/ffmpeg/encoder.c index bce9c4bba2..2a06d4c3b2 100755 --- a/ffmpeg/encoder.c +++ b/ffmpeg/encoder.c @@ -48,6 +48,7 @@ static int add_video_stream(struct output_ctx *octx, struct input_ctx *ictx) } else LPMS_ERR(add_video_err, "No video encoder, not a copy; what is this?"); octx->last_video_dts = AV_NOPTS_VALUE; + octx->last_enc_pts = AV_NOPTS_VALUE; return 0; add_video_err: @@ -519,8 +520,11 @@ int mux(AVPacket *pkt, AVRational tb, struct output_ctx *octx, AVStream *ost) pkt->pts = pkt->dts = pkt->pts + pkt->dts + octx->last_video_dts + 1 - FFMIN3(pkt->pts, pkt->dts, octx->last_video_dts + 1) - FFMAX3(pkt->pts, pkt->dts, octx->last_video_dts + 1); + } + // Match ffmpeg's mux behavior and clamp non-monotonic DTS separately, + // even when the packet did not trip the decoder's DTS > PTS repair path. + if (pkt->dts != AV_NOPTS_VALUE && octx->last_video_dts != AV_NOPTS_VALUE) { int64_t max = octx->last_video_dts + !(octx->oc->oformat->flags & AVFMT_TS_NONSTRICT); - // check if dts is bigger than previous last dts or not, not then that's non-monotonic if (pkt->dts < max) { if (pkt->pts >= pkt->dts) pkt->pts = FFMAX(pkt->pts, max); pkt->dts = max; @@ -644,10 +648,20 @@ int process_out(struct input_ctx *ictx, struct output_ctx *octx, AVCodecContext if (frame) { // rescale pts to match encoder timebase if necessary (eg, fps passthrough) AVRational filter_tb = av_buffersink_get_time_base(filter->sink_ctx); - if (av_cmp_q(filter_tb, encoder->time_base)) { + int pts_rescaled = av_cmp_q(filter_tb, encoder->time_base); + if (pts_rescaled) { frame->pts = av_rescale_q(frame->pts, filter_tb, encoder->time_base); // TODO does frame->duration needs to be rescaled too? } + // Handle timebase conversion collapsing adjacent PTS into the same encoder tick + if (is_video && pts_rescaled) { + if (octx->last_enc_pts != AV_NOPTS_VALUE && frame->pts <= octx->last_enc_pts) { + frame->pts = octx->last_enc_pts + 1; + AVRational ftb = av_buffersink_get_time_base(filter->sink_ctx); + frame->opaque = (void *)av_rescale_q(frame->pts, encoder->time_base, ftb); + } + octx->last_enc_pts = frame->pts; + } } // Check for runaway encodes where the FPS filter produces too many frames diff --git a/ffmpeg/filter.h b/ffmpeg/filter.h index b2f4b0fc48..7a6984caa3 100755 --- a/ffmpeg/filter.h +++ b/ffmpeg/filter.h @@ -62,6 +62,7 @@ struct output_ctx { int64_t last_audio_dts; //dts of the last audio packet sent to the muxer int64_t last_video_dts; //dts of the last video packet sent to the muxer + int64_t last_enc_pts; // last pts sent to the video encoder (in encoder timebase) int64_t gop_time, gop_pts_len, next_kf_pts; // for gop reset From c44af7253bc9aa99857e3007d8a13d1c4a1de90c Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Sun, 8 Mar 2026 22:23:28 -0700 Subject: [PATCH 6/6] Fix up SEI after picture data --- ffmpeg/ffmpeg.go | 8 + ffmpeg/sei_fixup.go | 341 +++++++++++++++++++++++++++++++++++++++ ffmpeg/sei_fixup_test.go | 123 ++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 ffmpeg/sei_fixup.go create mode 100644 ffmpeg/sei_fixup_test.go diff --git a/ffmpeg/ffmpeg.go b/ffmpeg/ffmpeg.go index 04c6fb93bf..38410203c0 100755 --- a/ffmpeg/ffmpeg.go +++ b/ffmpeg/ffmpeg.go @@ -912,6 +912,14 @@ func (t *Transcoder) Transcode(input *TranscodeOptionsIn, ps []TranscodeOptions) } } } + if format.Format == "mpegts" && format.Vcodec == "h264" { + if fixedPath, fixErr := FixMisplacedSEI(input.Fname); fixErr != nil { + glog.Warningf("SEI fix-up check failed for %s: %v", input.Fname, fixErr) + } else if fixedPath != input.Fname { + defer os.Remove(fixedPath) + input.Fname = fixedPath + } + } } hw_type, err := accelDeviceType(input.Accel) if err != nil { diff --git a/ffmpeg/sei_fixup.go b/ffmpeg/sei_fixup.go new file mode 100644 index 0000000000..1587b04106 --- /dev/null +++ b/ffmpeg/sei_fixup.go @@ -0,0 +1,341 @@ +package ffmpeg + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/livepeer/joy4/format/ts/tsio" +) + +const ( + tsPacketSize = 188 + invalidPID uint16 = 0x1fff +) + +type byteRange struct { + start int + end int +} + +type nalInfo struct { + start int + end int + typ uint8 +} + +// FixMisplacedSEI rewrites a TS segment into a temp file when SEI NAL units are +// found after VCL NAL units within an access unit. If no fix is needed, it +// returns the original input path. +func FixMisplacedSEI(inputPath string) (fixedPath string, err error) { + data, err := ioutil.ReadFile(inputPath) + if err != nil { + return "", err + } + fixedData, changed := fixSEIOrder(data) + if !changed { + return inputPath, nil + } + + dir := filepath.Dir(inputPath) + tmp, err := ioutil.TempFile(dir, "sei-fixup-*.ts") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(fixedData); err != nil { + tmp.Close() + os.Remove(tmpPath) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + +func fixSEIOrder(data []byte) ([]byte, bool) { + if len(data) < tsPacketSize { + return data, false + } + videoPID := findVideoPID(data) + if videoPID == invalidPID { + return data, false + } + + result := make([]byte, len(data)) + copy(result, data) + + var allPayload []byteRange + inVideoPES := false + for off := 0; off+tsPacketSize <= len(data); off += tsPacketSize { + pkt := data[off : off+tsPacketSize] + pid, start, _, hdrlen, err := tsio.ParseTSHeader(pkt) + if err != nil || hdrlen >= tsPacketSize { + continue + } + if pid != videoPID { + continue + } + payloadStart := off + hdrlen + payloadEnd := off + tsPacketSize + + if start { + inVideoPES = false + payload := pkt[hdrlen:] + if len(payload) < 9 || payload[0] != 0 || payload[1] != 0 || payload[2] != 1 { + continue + } + pesHdrLen, streamid, _, _, _, err := tsio.ParsePESHeader(payload) + if err != nil || streamid < 0xe0 || streamid > 0xef { + continue + } + payloadStart += pesHdrLen + if payloadStart > payloadEnd { + // Header spans this packet; skip payload bytes from this packet. + continue + } + inVideoPES = true + } + + if !inVideoPES { + continue + } + if payloadStart < payloadEnd { + allPayload = append(allPayload, byteRange{start: payloadStart, end: payloadEnd}) + } + } + if len(allPayload) == 0 { + return result, false + } + return result, fixPES(result, result, allPayload) +} + +func findVideoPID(data []byte) uint16 { + pmtPID := invalidPID + for off := 0; off+tsPacketSize <= len(data); off += tsPacketSize { + pkt := data[off : off+tsPacketSize] + pid, start, _, hdrlen, err := tsio.ParseTSHeader(pkt) + if err != nil || !start || hdrlen >= tsPacketSize { + continue + } + if pid != tsio.PAT_PID { + continue + } + payload := pkt[hdrlen:] + tableid, _, psihdrlen, datalen, err := tsio.ParsePSI(payload) + if err != nil || tableid != tsio.TableIdPAT { + continue + } + end := psihdrlen + datalen + if end > len(payload) || datalen <= 0 { + continue + } + var pat tsio.PAT + if _, err := pat.Unmarshal(payload[psihdrlen:end]); err != nil { + continue + } + for _, e := range pat.Entries { + if e.ProgramMapPID != 0 { + pmtPID = e.ProgramMapPID + break + } + } + if pmtPID != invalidPID { + break + } + } + + if pmtPID != invalidPID { + for off := 0; off+tsPacketSize <= len(data); off += tsPacketSize { + pkt := data[off : off+tsPacketSize] + pid, start, _, hdrlen, err := tsio.ParseTSHeader(pkt) + if err != nil || !start || hdrlen >= tsPacketSize || pid != pmtPID { + continue + } + payload := pkt[hdrlen:] + tableid, _, psihdrlen, datalen, err := tsio.ParsePSI(payload) + if err != nil || tableid != tsio.TableIdPMT { + continue + } + end := psihdrlen + datalen + if end > len(payload) || datalen <= 0 { + continue + } + var pmt tsio.PMT + if _, err := pmt.Unmarshal(payload[psihdrlen:end]); err != nil { + continue + } + for _, es := range pmt.ElementaryStreamInfos { + if es.StreamType == tsio.ElementaryStreamTypeH264 { + return es.ElementaryPID + } + } + } + } + + // Fallback for truncated segments that may not include PAT/PMT. + for off := 0; off+tsPacketSize <= len(data); off += tsPacketSize { + pkt := data[off : off+tsPacketSize] + pid, start, _, hdrlen, err := tsio.ParseTSHeader(pkt) + if err != nil || !start || hdrlen >= tsPacketSize { + continue + } + payload := pkt[hdrlen:] + if len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 { + if payload[3] >= 0xe0 && payload[3] <= 0xef { + return pid + } + } + } + return invalidPID +} + +func fixPES(orig, result []byte, ranges []byteRange) bool { + total := 0 + for _, r := range ranges { + if r.end > r.start { + total += r.end - r.start + } + } + if total == 0 { + return false + } + + es := make([]byte, 0, total) + for _, r := range ranges { + if r.end <= r.start || r.start < 0 || r.end > len(orig) { + return false + } + es = append(es, orig[r.start:r.end]...) + } + nals := scanNALs(es) + if len(nals) == 0 { + return false + } + + leading := es[:nals[0].start] + reordered := make([]byte, 0, len(es)) + reordered = append(reordered, leading...) + + var changed bool + appendSegment := func(seg []nalInfo) { + if len(seg) == 0 { + return + } + firstVCL := -1 + for i, n := range seg { + if n.typ >= 1 && n.typ <= 5 { + firstVCL = i + break + } + } + if firstVCL < 0 { + for _, n := range seg { + reordered = append(reordered, es[n.start:n.end]...) + } + return + } + misplacedSEI := false + for i := firstVCL + 1; i < len(seg); i++ { + if seg[i].typ == 6 { + misplacedSEI = true + break + } + } + if !misplacedSEI { + for _, n := range seg { + reordered = append(reordered, es[n.start:n.end]...) + } + return + } + + changed = true + for i := 0; i < firstVCL; i++ { + n := seg[i] + reordered = append(reordered, es[n.start:n.end]...) + } + for i := firstVCL; i < len(seg); i++ { + n := seg[i] + if n.typ == 6 { + reordered = append(reordered, es[n.start:n.end]...) + } + } + for i := firstVCL; i < len(seg); i++ { + n := seg[i] + if n.typ != 6 { + reordered = append(reordered, es[n.start:n.end]...) + } + } + } + + segStart := 0 + for i := 0; i <= len(nals); i++ { + segmentBoundary := i == len(nals) || (i > segStart && nals[i].typ == 9) + if !segmentBoundary { + continue + } + appendSegment(nals[segStart:i]) + segStart = i + } + if !changed { + return false + } + if len(reordered) != len(es) { + return false + } + + pos := 0 + for _, r := range ranges { + n := r.end - r.start + if n <= 0 { + continue + } + copy(result[r.start:r.end], reordered[pos:pos+n]) + pos += n + } + return pos == len(reordered) +} + +func scanNALs(es []byte) []nalInfo { + var nals []nalInfo + for pos := 0; pos < len(es); { + start, scLen := findStartCode(es, pos) + if start < 0 { + break + } + nextStart, _ := findStartCode(es, start+scLen) + end := len(es) + if nextStart >= 0 { + end = nextStart + } + if start+scLen < end { + nals = append(nals, nalInfo{ + start: start, + end: end, + typ: es[start+scLen] & 0x1f, + }) + } + if nextStart < 0 { + break + } + pos = nextStart + } + return nals +} + +func findStartCode(b []byte, from int) (int, int) { + for i := from; i+3 < len(b); i++ { + if b[i] != 0 || b[i+1] != 0 { + continue + } + if b[i+2] == 1 { + return i, 3 + } + if i+4 < len(b) && b[i+2] == 0 && b[i+3] == 1 { + return i, 4 + } + } + return -1, 0 +} diff --git a/ffmpeg/sei_fixup_test.go b/ffmpeg/sei_fixup_test.go new file mode 100644 index 0000000000..4a3a2f532e --- /dev/null +++ b/ffmpeg/sei_fixup_test.go @@ -0,0 +1,123 @@ +package ffmpeg + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFixMisplacedSEI_BrokenFiles(t *testing.T) { + run, dir := setupTest(t) + defer os.RemoveAll(dir) + + run(` + cat <<- 'EOF1' > input-sei-order.out + Access Unit Delimiter + Slice Header + Supplemental Enhancement Information + Access Unit Delimiter + EOF1 + + cat <<- 'EOF2' > fixed-sei-order.out + Access Unit Delimiter + Supplemental Enhancement Information + Slice Header + Access Unit Delimiter + EOF2 + + # missing-dts.ts has a SEI pre-prended, check that's preserved + cat <<- 'EOF3' > leading-sei.out + Supplemental Enhancement Information + Access Unit Delimiter + Slice Header + Supplemental Enhancement Information + EOF3 + + # missing-dts.ts has a SEI pre-prended, check that's preserved + cat <<- 'EOF3' > fixed-leading-sei.out + Supplemental Enhancement Information + Access Unit Delimiter + Supplemental Enhancement Information + Slice Header + EOF3 + + `) + + for _, name := range []string{"skip_1.ts", "skip_3.ts", "missing-dts.ts"} { + t.Run(name, func(t *testing.T) { + input := dataFilePath(t, name) + if "missing-dts.ts" == name { + checkNALSequence(t, run, input, "leading-sei.out") + } else { + checkNALSequence(t, run, input, "input-sei-order.out") + } + + inputData, err := ioutil.ReadFile(input) + require.NoError(t, err) + + fixedPath, err := FixMisplacedSEI(input) + require.NoError(t, err) + require.NotEqual(t, input, fixedPath, "expected fix-up to trigger") + defer os.Remove(fixedPath) + + fixedData, err := ioutil.ReadFile(fixedPath) + require.NoError(t, err) + require.Equal(t, len(inputData), len(fixedData), "fix-up must preserve byte size") + if "missing-dts.ts" == name { + checkNALSequence(t, run, fixedPath, "fixed-leading-sei.out") + } else { + checkNALSequence(t, run, fixedPath, "fixed-sei-order.out") + } + }) + } +} + +func TestFixMisplacedSEI_NoChanges(t *testing.T) { + run, dir := setupTest(t) + defer os.RemoveAll(dir) + + run(` + # normally SEI comes before any picture data + cat <<- 'EOF1' > vertical-sei-order.out + Access Unit Delimiter + Supplemental Enhancement Information + Slice Header + Access Unit Delimiter + EOF1 + + # this sample should NOT have any SEI + ! ffmpeg -hide_banner -i "$1/../data/portrait.ts" -c copy -bsf:v trace_headers -f null - 2>&1 | grep "Supplemental Enhancement Information" + `) + + checkNALSequence(t, run, dataFilePath(t, "vertical-sample.ts"), "vertical-sei-order.out") + + for _, name := range []string{"vertical-sample.ts", "portrait.ts", "broken-h264-parser.ts"} { + t.Run(name, func(t *testing.T) { + input := dataFilePath(t, name) + fixedPath, err := FixMisplacedSEI(input) + require.NoError(t, err) + require.Equal(t, input, fixedPath, "known-good sample should pass through unchanged") + }) + } +} + +func dataFilePath(t *testing.T, name string) string { + t.Helper() + wd, err := os.Getwd() + require.NoError(t, err) + return path.Join(wd, "..", "data", name) +} + +func checkNALSequence(t *testing.T, run func(cmd string) bool, inputPath, expectedPath string) { + t.Helper() + cmd := fmt.Sprintf(` + ffmpeg -hide_banner -i "%s" -c copy -bsf:v trace_headers -f null - 2>&1 | grep -e 'Access Unit\|Slice Header\|Supplement' | head -4 > pre.raw + sed -E 's/^\[[^]]+\] //' pre.raw > pre.out + diff -u %s pre.out + `, inputPath, expectedPath) + require.True(t, run(cmd), "NAL ordering check failed for %s", inputPath) +}