diff --git a/data/negative-timestamps.ts b/data/negative-timestamps.ts new file mode 100644 index 0000000000..0f1cdf117b Binary files /dev/null and b/data/negative-timestamps.ts differ diff --git a/ffmpeg/filter.c b/ffmpeg/filter.c index d9824cb23b..643b113229 100644 --- a/ffmpeg/filter.c +++ b/ffmpeg/filter.c @@ -343,6 +343,24 @@ int filtergraph_write(AVFrame *inf, struct input_ctx *ictx, struct output_ctx *o // 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); } + + // Check for negative timestamp steps and use a default frame duration instead + if (ts_step < 0) { + int64_t frame_duration = av_rescale_q_rnd(1, av_inv_q(octx->fps), vst->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX); + av_log(NULL, AV_LOG_WARNING, "Detected negative timestamp step (%lld) at PTS %lld. Using frame duration (%lld) instead.\n", + (long long)ts_step, (long long)inf->pts, (long long)frame_duration); + ts_step = frame_duration; + } + + // Check for abnormal positive steps + int64_t max_reasonable_step = av_rescale_q_rnd(10, av_inv_q(octx->fps), vst->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX); + if (ts_step > max_reasonable_step) { + int64_t frame_duration = av_rescale_q_rnd(1, av_inv_q(octx->fps), vst->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX); + av_log(NULL, AV_LOG_WARNING, "Detected abnormal timestamp step (%lld) at PTS %lld. Using frame duration (%lld) instead.\n", + (long long)ts_step, (long long)inf->pts, (long long)frame_duration); + ts_step = frame_duration; + } + filter->custom_pts += ts_step; filter->prev_frame_pts = inf->pts; } else { @@ -366,7 +384,7 @@ int filtergraph_write(AVFrame *inf, struct input_ctx *ictx, struct output_ctx *o if (inf) { // Apply the custom pts, then reset for the next output - int old_pts = inf->pts; + uint64_t old_pts = inf->pts; inf->pts = filter->custom_pts; ret = av_buffersrc_write_frame(filter->src_ctx, inf); inf->pts = old_pts; diff --git a/ffmpeg/timestamp_test.go b/ffmpeg/timestamp_test.go new file mode 100644 index 0000000000..1b78d7a419 --- /dev/null +++ b/ffmpeg/timestamp_test.go @@ -0,0 +1,80 @@ +package ffmpeg + +import ( + "os" + "testing" + "time" +) + +// Tests fix for VFR inputs causing infinite frame duplication, resulting in huge output files. +func TestTimestampRegression(t *testing.T) { + InitFFmpeg() + + inputFile := "../data/negative-timestamps.ts" + if _, err := os.Stat(inputFile); err != nil { + t.Skip("Problematic input file not available") + } + + // Test the exact pattern that triggered infinite loops: + // passthrough FPS followed by 30fps conversion + passthroughProfile := P240p30fps16x9 + passthroughProfile.Framerate = 0 // passthrough + + options := []TranscodeOptions{ + { + Oname: "test_passthrough.mp4", + Accel: Software, + Profile: passthroughProfile, + AudioEncoder: ComponentOptions{Name: "drop"}, + }, + { + Oname: "test_30fps.mp4", + Accel: Software, + Profile: P240p30fps16x9, + AudioEncoder: ComponentOptions{Name: "drop"}, + }, + } + + // Should complete quickly with fix, would hang without it + done := make(chan error, 1) + var result *TranscodeResults + + go func() { + var err error + result, err = Transcode3(&TranscodeOptionsIn{ + Fname: inputFile, + Accel: Software, + }, options) + done <- err + }() + + select { + case err := <-done: + if err != nil { + // Size limit error means protection worked + if err == ErrTranscoderOutputSize { + t.Log("Size limit triggered") + return + } + t.Fatal(err) + } + + if result == nil { + t.Fatal("No result") + } + + // Verify outputs are reasonably sized (not 20-190GB) + for _, opt := range options { + if stat, err := os.Stat(opt.Oname); err == nil { + size := stat.Size() + if size > 10*1024*1024 { // 10MB is suspicious for this input + t.Errorf("%s too large: %d bytes", opt.Oname, size) + } + os.Remove(opt.Oname) + } + } + + case <-time.After(30 * time.Second): + t.Fatal("Hung for 30s - infinite loop detected") + } +}