mirror of
https://github.com/OpenShot/libopenshot.git
synced 2026-10-14 03:30:59 -07:00
Align reduced preview dimensions after aspect fitting while preserving native output sizes. Pad export scaler rows and copy planes by stride, including compatibility handling for legacy GIF palettes. Preserve delayed audio encoder timestamps, skip unavailable packets, and use actual resampler output counts. Share JPEG-range normalization with live capture and add sizing, export, audio, GIF, and color regression tests.
520 lines
16 KiB
C++
520 lines
16 KiB
C++
/**
|
|
* @file
|
|
* @brief Unit tests for openshot::FFmpegWriter
|
|
* @author Jonathan Thomas <jonathan@openshot.org>
|
|
*
|
|
* @ref License
|
|
*/
|
|
|
|
// Copyright (c) 2008-2019 OpenShot Studios, LLC
|
|
//
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
|
|
#include <sstream>
|
|
#include <memory>
|
|
#include <fstream>
|
|
#include <QTemporaryDir>
|
|
|
|
#include "openshot_catch.h"
|
|
|
|
#include "FFmpegWriter.h"
|
|
#include "Exceptions.h"
|
|
#include "DummyReader.h"
|
|
#include "FFmpegReader.h"
|
|
#include "Fraction.h"
|
|
#include "Frame.h"
|
|
#include "Timeline.h"
|
|
|
|
extern "C" {
|
|
#include <libavformat/avformat.h>
|
|
}
|
|
|
|
using namespace std;
|
|
using namespace openshot;
|
|
|
|
namespace {
|
|
AVStream* first_video_stream(AVFormatContext* format_context)
|
|
{
|
|
for (unsigned int index = 0; index < format_context->nb_streams; ++index) {
|
|
AVStream* stream = format_context->streams[index];
|
|
if (stream && stream->codecpar && stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO)
|
|
return stream;
|
|
}
|
|
return nullptr;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Raw video export preserves all color planes and frame ownership",
|
|
"[libopenshot][ffmpegwriter][rawvideo]")
|
|
{
|
|
const int width = GENERATE(64, 1080);
|
|
QTemporaryDir directory;
|
|
REQUIRE(directory.isValid());
|
|
// NUT uses a different stream time base from the codec's frame rate.
|
|
const auto filename = GENERATE("raw.avi", "raw.nut");
|
|
const std::string path = directory.filePath(filename).toStdString();
|
|
// Multiple frames and repeated exports exercise packet/frame cleanup.
|
|
for (int pass = 0; pass < 2; ++pass) {
|
|
FFmpegWriter writer(path);
|
|
writer.SetVideoOptions(true, "rawvideo", Fraction(30, 1), width, 64,
|
|
Fraction(1, 1), false, false, 1000000);
|
|
writer.Open();
|
|
for (int number = 1; number <= 3; ++number) {
|
|
auto frame = std::make_shared<Frame>(number, width, 64, number == 2 ? "blue" : "red");
|
|
writer.WriteFrame(frame);
|
|
}
|
|
writer.Close();
|
|
|
|
AVFormatContext* input = nullptr;
|
|
REQUIRE(avformat_open_input(&input, path.c_str(), nullptr, nullptr) == 0);
|
|
std::unique_ptr<AVFormatContext, void(*)(AVFormatContext*)> input_guard(
|
|
input, [](AVFormatContext* context) { avformat_close_input(&context); });
|
|
REQUIRE(avformat_find_stream_info(input, nullptr) >= 0);
|
|
AVStream* stream = first_video_stream(input);
|
|
REQUIRE(stream != nullptr);
|
|
AVPacket packet = {};
|
|
int packets = 0;
|
|
while (av_read_frame(input, &packet) >= 0) {
|
|
if (packet.stream_index == stream->index) {
|
|
CHECK(packet.size == width * 64 * 3 / 2); // Complete YUV420P image
|
|
CHECK(packet.pts * av_q2d(stream->time_base) == Approx(packets / 30.0).margin(0.00001));
|
|
++packets;
|
|
}
|
|
av_packet_unref(&packet);
|
|
}
|
|
CHECK(packets == 3);
|
|
input_guard.reset();
|
|
|
|
// NUT's reported duration omits the final frame interval in this FFmpeg
|
|
// version; verify its packets above and use AVI for reader round trips.
|
|
if (std::string(filename) == "raw.nut") continue;
|
|
FFmpegReader reader(path);
|
|
reader.Open();
|
|
CHECK(reader.info.video_length == 3);
|
|
for (int number = 1; number <= 3; ++number) {
|
|
auto frame = reader.GetFrame(number);
|
|
REQUIRE(frame->GetWidth() == width);
|
|
REQUIRE(frame->GetHeight() == 64);
|
|
const QColor color = frame->GetImage()->pixelColor(width - 1, 63);
|
|
CHECK(color.green() < 10);
|
|
CHECK(color.red() == Approx(number == 2 ? 0 : 255).margin(10));
|
|
CHECK(color.blue() == Approx(number == 2 ? 255 : 0).margin(10));
|
|
}
|
|
reader.Close();
|
|
}
|
|
}
|
|
|
|
TEST_CASE("GIF export copies padded RGB8 rows without a synthetic palette",
|
|
"[libopenshot][ffmpegwriter][padded-gif]")
|
|
{
|
|
QTemporaryDir directory;
|
|
REQUIRE(directory.isValid());
|
|
const std::string path = directory.filePath("padded.gif").toStdString();
|
|
FFmpegWriter writer(path);
|
|
writer.SetVideoOptions(true, "gif", Fraction(25, 1), 1080, 32,
|
|
Fraction(1, 1), false, false, 1000000);
|
|
writer.Open();
|
|
for (int number = 1; number <= 3; ++number)
|
|
writer.WriteFrame(std::make_shared<Frame>(number, 1080, 32, "red"));
|
|
writer.Close();
|
|
FFmpegReader reader(path);
|
|
reader.Open();
|
|
auto frame = reader.GetFrame(1);
|
|
REQUIRE(frame->GetWidth() == 1080);
|
|
REQUIRE(frame->GetHeight() == 32);
|
|
const QColor color = frame->GetImage()->pixelColor(1079, 31);
|
|
CHECK(color.red() > 230);
|
|
CHECK(color.green() < 20);
|
|
CHECK(color.blue() < 20);
|
|
reader.Close();
|
|
}
|
|
|
|
TEST_CASE( "Webm", "[libopenshot][ffmpegwriter]" )
|
|
{
|
|
// Reader
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
FFmpegReader r(path.str());
|
|
r.Open();
|
|
|
|
/* WRITER ---------------- */
|
|
FFmpegWriter w("Webm-output1.webm");
|
|
|
|
// Set options
|
|
w.SetAudioOptions(true, "libvorbis", 44100, 2, LAYOUT_STEREO, 188000);
|
|
w.SetVideoOptions(true, "libvpx", Fraction(24,1), 1280, 720, Fraction(1,1), false, false, 30000000);
|
|
|
|
// Open writer
|
|
w.Open();
|
|
|
|
// Write some frames
|
|
w.WriteFrame(&r, 24, 50);
|
|
|
|
// Close writer & reader
|
|
w.Close();
|
|
r.Close();
|
|
|
|
FFmpegReader r1("Webm-output1.webm");
|
|
r1.Open();
|
|
|
|
// Verify various settings on new MP4
|
|
CHECK(r1.GetFrame(1)->GetAudioChannelsCount() == 2);
|
|
CHECK(r1.info.fps.num == 24);
|
|
CHECK(r1.info.fps.den == 1);
|
|
|
|
// Get a specific frame
|
|
std::shared_ptr<Frame> f = r1.GetFrame(8);
|
|
|
|
// Get the image data for row 500
|
|
const unsigned char* pixels = f->GetPixels(500);
|
|
int pixel_index = 112 * 4; // pixel 112 (4 bytes per pixel)
|
|
|
|
// Check image properties on scanline 10, pixel 112
|
|
CHECK((int)pixels[pixel_index] == Approx(23).margin(7));
|
|
CHECK((int)pixels[pixel_index + 1] == Approx(23).margin(7));
|
|
CHECK((int)pixels[pixel_index + 2] == Approx(23).margin(7));
|
|
CHECK((int)pixels[pixel_index + 3] == Approx(255).margin(7));
|
|
}
|
|
|
|
TEST_CASE( "Options_Overloads", "[libopenshot][ffmpegwriter]" )
|
|
{
|
|
// Reader
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
FFmpegReader r(path.str());
|
|
r.Open();
|
|
|
|
/* WRITER ---------------- */
|
|
FFmpegWriter w("Options_Overloads-output1.mp4");
|
|
|
|
// Set options
|
|
w.SetAudioOptions("aac", 48000, 192000);
|
|
w.SetVideoOptions("libx264", 1280, 720, Fraction(30,1), 5000000);
|
|
|
|
// Open writer
|
|
w.Open();
|
|
|
|
// Write some frames
|
|
w.WriteFrame(&r, 24, 50);
|
|
|
|
// Close writer & reader
|
|
w.Close();
|
|
r.Close();
|
|
|
|
FFmpegReader r1("Options_Overloads-output1.mp4");
|
|
r1.Open();
|
|
|
|
// Verify implied settings
|
|
CHECK(r1.info.has_audio == true);
|
|
CHECK(r1.info.has_video == true);
|
|
|
|
CHECK(r1.GetFrame(1)->GetAudioChannelsCount() == 2);
|
|
CHECK(r1.info.channel_layout == LAYOUT_STEREO);
|
|
|
|
CHECK(r1.info.pixel_ratio.num == 1);
|
|
CHECK(r1.info.pixel_ratio.den == 1);
|
|
CHECK_FALSE(r1.info.interlaced_frame);
|
|
CHECK(r1.info.top_field_first == true);
|
|
}
|
|
|
|
|
|
TEST_CASE( "DisplayInfo", "[libopenshot][ffmpegwriter]" )
|
|
{
|
|
// Reader
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
FFmpegReader r(path.str());
|
|
r.Open();
|
|
|
|
/* WRITER ---------------- */
|
|
FFmpegWriter w("DisplayInfo-output1.webm");
|
|
|
|
// Set options
|
|
w.SetAudioOptions(true, "libvorbis", 44100, 2, LAYOUT_STEREO, 188000);
|
|
w.SetVideoOptions(
|
|
true, "libvpx",
|
|
Fraction(24,1),
|
|
1280, 720,
|
|
Fraction(1,1),
|
|
false, false,
|
|
30000000);
|
|
|
|
// Open writer
|
|
w.Open();
|
|
|
|
std::string expected(
|
|
R"(----------------------------
|
|
----- File Information -----
|
|
----------------------------
|
|
--> Has Video: true
|
|
--> Has Audio: true
|
|
--> Has Single Image: false
|
|
--> Duration: 0.00 Seconds
|
|
--> File Size: 0.00 MB
|
|
----------------------------
|
|
----- Video Attributes -----
|
|
----------------------------
|
|
--> Width: 1280
|
|
--> Height: 720
|
|
--> Pixel Format: -1
|
|
--> Frames Per Second: 24.00 (24/1)
|
|
--> Video Bit Rate: 30000 kb/s
|
|
--> Pixel Ratio: 1.00 (1/1)
|
|
--> Display Aspect Ratio: 1.78 (16/9)
|
|
--> Video Codec: libvpx
|
|
--> Video Length: 0 Frames
|
|
--> Video Stream Index: -1
|
|
--> Video Timebase: 0.04 (1/24)
|
|
--> Interlaced: false
|
|
--> Interlaced: Top Field First: false
|
|
----------------------------
|
|
----- Audio Attributes -----
|
|
----------------------------
|
|
--> Audio Codec: libvorbis
|
|
--> Audio Bit Rate: 188 kb/s
|
|
--> Sample Rate: 44100 Hz
|
|
--> # of Channels: 2
|
|
--> Channel Layout: 3
|
|
--> Audio Stream Index: -1
|
|
--> Audio Timebase: 1.00 (1/1)
|
|
----------------------------)");
|
|
|
|
// Store the DisplayInfo() text in 'output'
|
|
std::stringstream output;
|
|
w.DisplayInfo(&output);
|
|
|
|
w.Close();
|
|
|
|
// Compare a [0, expected.size()) substring of output to expected
|
|
CHECK(output.str().substr(0, expected.size()) == expected);
|
|
}
|
|
|
|
TEST_CASE( "Gif", "[libopenshot][ffmpegwriter]" )
|
|
{
|
|
// Reader
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
|
|
// Create Gif Clip
|
|
Clip clip_video(path.str());
|
|
clip_video.Layer(0);
|
|
clip_video.Position(0.0);
|
|
clip_video.Open();
|
|
|
|
// Create Timeline w/ 1 Gif Clip (with 0 sample rate, and 0 channels)
|
|
openshot::Timeline t(1280, 720, Fraction(30,1), 0, 0, LAYOUT_MONO);
|
|
t.AddClip(&clip_video);
|
|
t.Open();
|
|
|
|
/* WRITER ---------------- */
|
|
FFmpegWriter w("Gif-output1.gif");
|
|
|
|
// Set options (no audio options are set)
|
|
w.SetVideoOptions(true, "gif", Fraction(24,1), 1280, 720, Fraction(1,1), false, false, 15000000);
|
|
|
|
// Create streams
|
|
w.PrepareStreams();
|
|
|
|
// Open writer
|
|
w.Open();
|
|
|
|
// Write some frames
|
|
w.WriteFrame(&t, 1, 60);
|
|
|
|
// Close writer & reader
|
|
w.Close();
|
|
t.Close();
|
|
|
|
FFmpegReader r1("Gif-output1.gif");
|
|
r1.Open();
|
|
|
|
// Verify various settings on new Gif
|
|
CHECK(r1.GetFrame(1)->GetAudioChannelsCount() == 0);
|
|
CHECK(r1.GetFrame(1)->GetAudioSamplesCount() == 0);
|
|
CHECK(r1.info.fps.num == 24);
|
|
CHECK(r1.info.fps.den == 1);
|
|
|
|
// Close reader
|
|
r1.Close();
|
|
}
|
|
|
|
TEST_CASE( "MP4_30fps_duration_exact_with_b_frames", "[libopenshot][ffmpegwriter][fps]" )
|
|
{
|
|
const std::string out_name = "MP4_30fps_duration_exact_with_b_frames.mp4";
|
|
DummyReader reader(Fraction(30, 1), 1280, 720, 48000, 2, 1.0f);
|
|
reader.Open();
|
|
|
|
FFmpegWriter writer(out_name);
|
|
writer.SetVideoOptions(true, "mpeg4", Fraction(30, 1), 1280, 720, Fraction(1, 1), false, false, 15000000);
|
|
writer.Open();
|
|
writer.WriteFrame(&reader, 1, 30);
|
|
writer.Close();
|
|
reader.Close();
|
|
|
|
AVFormatContext* format_context = nullptr;
|
|
REQUIRE(avformat_open_input(&format_context, out_name.c_str(), nullptr, nullptr) == 0);
|
|
REQUIRE(avformat_find_stream_info(format_context, nullptr) >= 0);
|
|
AVStream* stream = first_video_stream(format_context);
|
|
REQUIRE(stream != nullptr);
|
|
|
|
CHECK(stream->r_frame_rate.num == 30);
|
|
CHECK(stream->r_frame_rate.den == 1);
|
|
CHECK(stream->avg_frame_rate.num == 30);
|
|
CHECK(stream->avg_frame_rate.den == 1);
|
|
CHECK(stream->duration == av_rescale_q(30, av_make_q(1, 30), stream->time_base));
|
|
|
|
avformat_close_input(&format_context);
|
|
}
|
|
|
|
TEST_CASE( "WriteFrameAt_preserves_sparse_video_timestamps", "[libopenshot][ffmpegwriter][fps]" )
|
|
{
|
|
const std::string out_name = "WriteFrameAt_sparse_timestamps.mp4";
|
|
DummyReader reader(Fraction(30, 1), 1280, 720, 0, 0, 1.0f);
|
|
reader.Open();
|
|
|
|
FFmpegWriter writer(out_name);
|
|
writer.SetVideoOptions(true, "libx264", Fraction(30, 1), 1280, 720, Fraction(1, 1), false, false, 15000000);
|
|
writer.Open();
|
|
writer.WriteFrameAt(reader.GetFrame(1), 1);
|
|
writer.WriteFrameAt(reader.GetFrame(2), 2);
|
|
writer.WriteFrameAt(reader.GetFrame(3), 10);
|
|
writer.Close();
|
|
reader.Close();
|
|
|
|
AVFormatContext* format_context = nullptr;
|
|
REQUIRE(avformat_open_input(&format_context, out_name.c_str(), nullptr, nullptr) == 0);
|
|
REQUIRE(avformat_find_stream_info(format_context, nullptr) >= 0);
|
|
AVStream* stream = first_video_stream(format_context);
|
|
REQUIRE(stream != nullptr);
|
|
|
|
CHECK(stream->duration == av_rescale_q(10, av_make_q(1, 30), stream->time_base));
|
|
|
|
avformat_close_input(&format_context);
|
|
}
|
|
|
|
TEST_CASE( "SizeOrdering_x264_CRF", "[libopenshot][ffmpegwriter][filesize]" )
|
|
{
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
FFmpegReader reader(path.str());
|
|
reader.Open();
|
|
|
|
auto file_size = [](const std::string& file_path) -> std::streamoff {
|
|
std::ifstream in(file_path, std::ios::binary | std::ios::ate);
|
|
if (!in)
|
|
return -1;
|
|
return in.tellg();
|
|
};
|
|
|
|
auto encode = [&](const std::string& out_name, int crf, int audio_bitrate) -> std::streamoff {
|
|
FFmpegWriter w(out_name);
|
|
w.SetAudioOptions(true, "aac", 48000, 2, LAYOUT_STEREO, audio_bitrate);
|
|
w.SetVideoOptions(true, "libx264", Fraction(24,1), 1280, 720, Fraction(1,1), false, false, crf);
|
|
w.PrepareStreams();
|
|
w.SetOption(VIDEO_STREAM, "crf", std::to_string(crf));
|
|
w.Open();
|
|
w.WriteFrame(&reader, 1, 120);
|
|
w.Close();
|
|
return file_size(out_name);
|
|
};
|
|
|
|
const auto low_size = encode("SizeOrdering_x264_CRF_low.mp4", 30, 96000);
|
|
const auto med_size = encode("SizeOrdering_x264_CRF_med.mp4", 23, 128000);
|
|
const auto high_size = encode("SizeOrdering_x264_CRF_high.mp4", 20, 160000);
|
|
|
|
CHECK(low_size < med_size);
|
|
CHECK(med_size < high_size);
|
|
|
|
reader.Close();
|
|
}
|
|
|
|
TEST_CASE( "SizeOrdering_vp9_CRF", "[libopenshot][ffmpegwriter][filesize]" )
|
|
{
|
|
std::stringstream path;
|
|
path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
|
|
FFmpegReader reader(path.str());
|
|
reader.Open();
|
|
|
|
auto file_size = [](const std::string& file_path) -> std::streamoff {
|
|
std::ifstream in(file_path, std::ios::binary | std::ios::ate);
|
|
if (!in)
|
|
return -1;
|
|
return in.tellg();
|
|
};
|
|
|
|
auto encode = [&](const std::string& out_name, int crf, int audio_bitrate) -> std::streamoff {
|
|
FFmpegWriter w(out_name);
|
|
w.SetAudioOptions(true, "libvorbis", 48000, 2, LAYOUT_STEREO, audio_bitrate);
|
|
w.SetVideoOptions(true, "libvpx-vp9", Fraction(24,1), 1280, 720, Fraction(1,1), false, false, crf);
|
|
w.PrepareStreams();
|
|
w.SetOption(VIDEO_STREAM, "crf", std::to_string(crf));
|
|
w.Open();
|
|
w.WriteFrame(&reader, 1, 120);
|
|
w.Close();
|
|
return file_size(out_name);
|
|
};
|
|
|
|
const auto low_size = encode("SizeOrdering_vp9_CRF_low.webm", 46, 96000);
|
|
const auto med_size = encode("SizeOrdering_vp9_CRF_med.webm", 34, 128000);
|
|
const auto high_size = encode("SizeOrdering_vp9_CRF_high.webm", 26, 160000);
|
|
|
|
CHECK(low_size < med_size);
|
|
CHECK(med_size < high_size);
|
|
|
|
reader.Close();
|
|
}
|
|
|
|
TEST_CASE("AAC export preserves delayed packet timestamps through flush",
|
|
"[libopenshot][ffmpegwriter][audio-timestamps]")
|
|
{
|
|
QTemporaryDir directory;
|
|
REQUIRE(directory.isValid());
|
|
const std::string path = directory.filePath("aac.mp4").toStdString();
|
|
const Fraction fps(30000, 1001);
|
|
const int source_rate = GENERATE(48000, 44100);
|
|
FFmpegWriter writer(path);
|
|
writer.SetVideoOptions(true, "libx264", fps, 16, 16, Fraction(1, 1), false, false, 100000);
|
|
writer.SetAudioOptions(true, "aac", 48000, 2, LAYOUT_STEREO, 160000);
|
|
writer.Open();
|
|
int total_samples = 0;
|
|
for (int number = 1; number <= 230; ++number) {
|
|
const int samples = Frame::GetSamplesPerFrame(number, fps, source_rate, 2);
|
|
auto frame = std::make_shared<Frame>(number, 16, 16, "red", samples, 2);
|
|
frame->SampleRate(source_rate);
|
|
frame->ChannelsLayout(LAYOUT_STEREO);
|
|
frame->AddAudioSilence(samples);
|
|
writer.WriteFrame(frame);
|
|
total_samples += samples;
|
|
}
|
|
writer.Close();
|
|
AVFormatContext* input = nullptr;
|
|
REQUIRE(avformat_open_input(&input, path.c_str(), nullptr, nullptr) == 0);
|
|
std::unique_ptr<AVFormatContext, void(*)(AVFormatContext*)> guard(
|
|
input, [](AVFormatContext* context) { avformat_close_input(&context); });
|
|
REQUIRE(avformat_find_stream_info(input, nullptr) >= 0);
|
|
const int index = av_find_best_stream(input, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
|
|
REQUIRE(index >= 0);
|
|
AVStream* stream = input->streams[index];
|
|
AVPacket packet = {};
|
|
int count = 0;
|
|
int64_t previous = AV_NOPTS_VALUE;
|
|
while (av_read_frame(input, &packet) >= 0) {
|
|
if (packet.stream_index == index) {
|
|
REQUIRE(packet.dts != AV_NOPTS_VALUE);
|
|
CHECK(packet.pts == packet.dts);
|
|
const int64_t sample_dts = av_rescale_q(packet.dts, stream->time_base, AVRational{1, 48000});
|
|
if (count) CHECK(sample_dts - previous == 1024);
|
|
else CHECK(sample_dts == -1024); // AAC encoder priming
|
|
previous = sample_dts;
|
|
++count;
|
|
}
|
|
av_packet_unref(&packet);
|
|
}
|
|
const int64_t expected_samples = av_rescale(total_samples, 48000, source_rate);
|
|
CHECK(count == (expected_samples + 1023) / 1024 + 1);
|
|
// MP4 edit-list durations round to milliseconds (48 samples). Rate
|
|
// conversion can also retain a short filter tail in the resampler.
|
|
CHECK(av_rescale_q(stream->duration, stream->time_base, AVRational{1, 48000})
|
|
== Approx(expected_samples).margin(source_rate == 48000 ? 48 : 96));
|
|
}
|