diff --git a/src/Exceptions.h b/src/Exceptions.h index 9e2f2b83..60a55c14 100644 --- a/src/Exceptions.h +++ b/src/Exceptions.h @@ -62,15 +62,21 @@ namespace openshot { { public: std::string file_path; + std::string full_message; FileExceptionBase(std::string message, std::string file_path="") - : ExceptionBase(message), file_path(file_path) { } - virtual std::string py_message() const override { - // return complete message for Python exception handling - std::string out_msg(m_message + + : ExceptionBase(message), + file_path(file_path), + full_message(message + (file_path != "" ? " for file " + file_path - : "")); - return out_msg; + : "")) { } + virtual const char* what() const noexcept override { + // Include file path in native C++ exception output (stderr / terminate). + return full_message.c_str(); + } + virtual std::string py_message() const override { + // Keep Python exception output consistent with what(). + return full_message; } }; diff --git a/src/FFmpegReader.cpp b/src/FFmpegReader.cpp index 72e3cb57..5aa49f2a 100644 --- a/src/FFmpegReader.cpp +++ b/src/FFmpegReader.cpp @@ -274,7 +274,7 @@ void FFmpegReader::Open() { // Open video file if (avformat_open_input(&pFormatCtx, path.c_str(), NULL, NULL) != 0) - throw InvalidFile("File could not be opened.", path); + throw InvalidFile("FFmpegReader could not open media file.", path); // Retrieve stream information if (avformat_find_stream_info(pFormatCtx, NULL) < 0) @@ -1143,6 +1143,29 @@ void FFmpegReader::UpdateVideoInfo() { ApplyDurationStrategy(); + // Normalize FFmpeg-decoded still images (e.g. JPG/JPEG) to match image-reader behavior. + // This keeps timing/flags consistent regardless of which reader path was used. + if (!info.has_single_image && audioStream < 0) { + const AVCodecID codec_id = AV_FIND_DECODER_CODEC_ID(pStream); + const bool likely_still_codec = + codec_id == AV_CODEC_ID_MJPEG || + codec_id == AV_CODEC_ID_PNG || + codec_id == AV_CODEC_ID_BMP || + codec_id == AV_CODEC_ID_TIFF || + codec_id == AV_CODEC_ID_WEBP || + codec_id == AV_CODEC_ID_JPEG2000; + const bool likely_image_demuxer = + pFormatCtx && pFormatCtx->iformat && pFormatCtx->iformat->name && + strstr(pFormatCtx->iformat->name, "image2"); + const bool single_frame_clip = info.video_length <= 1; + + if (single_frame_clip && (likely_still_codec || likely_image_demuxer)) { + info.has_single_image = true; + record_duration(video_stream_duration_seconds, 60 * 60 * 1); // 1 hour duration + ApplyDurationStrategy(); + } + } + // Add video metadata (if any) AVDictionaryEntry *tag = NULL; while ((tag = av_dict_get(pStream->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) { @@ -2816,10 +2839,4 @@ void FFmpegReader::SetJsonValue(const Json::Value root) { duration_strategy = DurationStrategy::LongestStream; } } - - // Re-Open path, and re-init everything (if needed) - if (is_open) { - Close(); - Open(); - } } diff --git a/src/FrameMapper.cpp b/src/FrameMapper.cpp index 5e678b5b..937f828b 100644 --- a/src/FrameMapper.cpp +++ b/src/FrameMapper.cpp @@ -800,13 +800,6 @@ void FrameMapper::SetJsonValue(const Json::Value root) { // Set parent data ReaderBase::SetJsonValue(root); - - // Re-Open path, and re-init everything (if needed) - if (reader) { - - Close(); - Open(); - } } // Change frame rate or audio mapping details diff --git a/src/ImageReader.cpp b/src/ImageReader.cpp index e3342a41..0b9c4abd 100644 --- a/src/ImageReader.cpp +++ b/src/ImageReader.cpp @@ -49,7 +49,7 @@ void ImageReader::Open() } catch (const Magick::Exception& e) { // raise exception - throw InvalidFile("File could not be opened.", path); + throw InvalidFile("ImageReader could not open image file.", path); } // Update image properties diff --git a/src/Profiles.cpp b/src/Profiles.cpp index d76557d4..088a6b1f 100644 --- a/src/Profiles.cpp +++ b/src/Profiles.cpp @@ -129,13 +129,13 @@ Profile::Profile(std::string path) { catch (const std::exception& e) { // Error parsing profile file - throw InvalidFile("Profile could not be found or loaded (or is invalid).", path); + throw InvalidFile("Profile file could not be parsed (invalid format or values).", path); } // Throw error if file was not read if (!read_file) // Error parsing profile file - throw InvalidFile("Profile could not be found or loaded (or is invalid).", path); + throw InvalidFile("Profile file could not be found or opened.", path); } // Return a formatted FPS diff --git a/src/QtImageReader.cpp b/src/QtImageReader.cpp index ffd20862..5504a808 100644 --- a/src/QtImageReader.cpp +++ b/src/QtImageReader.cpp @@ -77,7 +77,7 @@ void QtImageReader::Open() if (!loaded) { // raise exception - throw InvalidFile("File could not be opened.", path.toStdString()); + throw InvalidFile("QtImageReader could not open image file.", path.toStdString()); } // Update image properties @@ -367,11 +367,4 @@ void QtImageReader::SetJsonValue(const Json::Value root) { // Set data from Json (if key is found) if (!root["path"].isNull()) path = QString::fromStdString(root["path"].asString()); - - // Re-Open path, and re-init everything (if needed) - if (is_open) - { - Close(); - Open(); - } } diff --git a/src/Timeline.cpp b/src/Timeline.cpp index 37363645..f8648423 100644 --- a/src/Timeline.cpp +++ b/src/Timeline.cpp @@ -102,7 +102,7 @@ Timeline::Timeline(const std::string& projectPath, bool convert_absolute_paths) // Check if path exists QFileInfo filePath(QString::fromStdString(path)); if (!filePath.exists()) { - throw InvalidFile("File could not be opened.", path); + throw InvalidFile("Timeline project file could not be opened.", path); } // Check OpenShot Install Path exists diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2a9a72f7..82f4d07a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -64,6 +64,7 @@ set(OPENSHOT_TESTS # ImageMagick related test files if($CACHE{HAVE_IMAGEMAGICK}) list(APPEND OPENSHOT_TESTS + ImageReader ImageWriter ) endif() diff --git a/tests/FFmpegReader.cpp b/tests/FFmpegReader.cpp index 0542df6b..8c83d10e 100644 --- a/tests/FFmpegReader.cpp +++ b/tests/FFmpegReader.cpp @@ -13,6 +13,10 @@ #include #include #include +#include +#include +#include +#include #include "openshot_catch.h" @@ -26,8 +30,16 @@ using namespace openshot; TEST_CASE( "Invalid_Path", "[libopenshot][ffmpegreader]" ) { - // Check invalid path - CHECK_THROWS_AS(FFmpegReader(""), InvalidFile); + // Check invalid path and error details + const std::string invalid_path = "/tmp/__openshot_missing_test_file__.mp4"; + try { + FFmpegReader r(invalid_path); + FAIL("Expected InvalidFile for missing media path"); + } catch (const InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("FFmpegReader could not open media file.") != std::string::npos); + CHECK(message.find(invalid_path) != std::string::npos); + } } TEST_CASE( "GetFrame_Before_Opening", "[libopenshot][ffmpegreader]" ) @@ -348,6 +360,68 @@ TEST_CASE( "Multiple_Open_and_Close", "[libopenshot][ffmpegreader]" ) r.Close(); } +TEST_CASE( "Static_Image_PNG_Reports_Single_Image", "[libopenshot][ffmpegreader]" ) +{ + std::stringstream path; + path << TEST_MEDIA_PATH << "front.png"; + FFmpegReader r(path.str(), DurationStrategy::VideoPreferred); + r.Open(); + + CHECK(r.info.has_video); + CHECK_FALSE(r.info.has_audio); + CHECK(r.info.has_single_image); + CHECK(r.info.video_length > 1); + CHECK(r.info.duration > 1000.0f); + + auto f1 = r.GetFrame(1); + auto f2 = r.GetFrame(std::min(2, static_cast(r.info.video_length))); + CHECK(f1->CheckPixel(50, 50, + f2->GetPixels(50)[50 * 4 + 0], + f2->GetPixels(50)[50 * 4 + 1], + f2->GetPixels(50)[50 * 4 + 2], + f2->GetPixels(50)[50 * 4 + 3], + 0)); + + r.Close(); +} + +TEST_CASE( "Static_Image_JPG_Reports_Single_Image", "[libopenshot][ffmpegreader]" ) +{ + // Generate a JPG fixture at runtime from a known PNG frame. + std::stringstream png_path; + png_path << TEST_MEDIA_PATH << "front.png"; + FFmpegReader png_reader(png_path.str()); + png_reader.Open(); + + auto png_frame = png_reader.GetFrame(1); + std::srand(static_cast(std::time(nullptr))); + std::stringstream jpg_path; + jpg_path << "libopenshot-static-image-test-" << std::rand() << ".jpg"; + REQUIRE(png_frame->GetImage()->save(jpg_path.str().c_str(), "JPG")); + png_reader.Close(); + + FFmpegReader jpg_reader(jpg_path.str(), DurationStrategy::VideoPreferred); + jpg_reader.Open(); + + CHECK(jpg_reader.info.has_video); + CHECK_FALSE(jpg_reader.info.has_audio); + CHECK(jpg_reader.info.has_single_image); + CHECK(jpg_reader.info.video_length > 1); + CHECK(jpg_reader.info.duration > 1000.0f); + + auto f1 = jpg_reader.GetFrame(1); + auto f2 = jpg_reader.GetFrame(std::min(2, static_cast(jpg_reader.info.video_length))); + CHECK(f1->CheckPixel(50, 50, + f2->GetPixels(50)[50 * 4 + 0], + f2->GetPixels(50)[50 * 4 + 1], + f2->GetPixels(50)[50 * 4 + 2], + f2->GetPixels(50)[50 * 4 + 3], + 2)); + + jpg_reader.Close(); + std::remove(jpg_path.str().c_str()); +} + TEST_CASE( "verify parent Timeline", "[libopenshot][ffmpegreader]" ) { // Create a reader diff --git a/tests/ImageReader.cpp b/tests/ImageReader.cpp index 9232fb08..8e81d08c 100644 --- a/tests/ImageReader.cpp +++ b/tests/ImageReader.cpp @@ -19,6 +19,20 @@ using namespace openshot; +TEST_CASE( "Invalid_Path_ImageReader", "[libopenshot][imagereader]" ) +{ + // Check invalid path and error details + const std::string invalid_path = "/tmp/__openshot_missing_test_file__.png"; + try { + ImageReader r(invalid_path); + FAIL("Expected InvalidFile for missing image path"); + } catch (const InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("ImageReader could not open image file.") != std::string::npos); + CHECK(message.find(invalid_path) != std::string::npos); + } +} + TEST_CASE( "Duration_And_Length_ImageReader", "[libopenshot][imagereader]" ) { // Create a reader diff --git a/tests/Profiles.cpp b/tests/Profiles.cpp index a76caf9c..97c557a7 100644 --- a/tests/Profiles.cpp +++ b/tests/Profiles.cpp @@ -13,18 +13,30 @@ #include "openshot_catch.h" #include #include -#include - -#include +#include +#include +#include "Exceptions.h" #include "Profiles.h" -static std::string test_output_profile_path(const std::string& base_name) { - std::stringstream path; - path << QDir::currentPath().toStdString() << "/" << base_name - << "_" << getpid() << "_" << rand(); - return path.str(); +static std::string get_temp_test_path(const std::string& file_name) { +#ifdef _WIN32 + const char* base = std::getenv("TEMP"); + if (!base || !*base) { + base = std::getenv("TMP"); + } + if (!base || !*base) { + base = "."; + } + return std::string(base) + "\\" + file_name; +#else + const char* base = std::getenv("TMPDIR"); + if (!base || !*base) { + base = "/tmp"; + } + return std::string(base) + "/" + file_name; +#endif } TEST_CASE( "empty constructor", "[libopenshot][profile]" ) @@ -101,6 +113,38 @@ TEST_CASE( "constructor with example profiles", "[libopenshot][profile]" ) CHECK(p2.info.spherical == false); } +TEST_CASE( "invalid profile path message", "[libopenshot][profile]" ) +{ + const std::string invalid_path = get_temp_test_path("__openshot_missing_test_profile__"); + std::remove(invalid_path.c_str()); + try { + openshot::Profile p(invalid_path); + FAIL("Expected InvalidFile for missing profile path"); + } catch (const openshot::InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("Profile file could not be found or opened.") != std::string::npos); + CHECK(message.find(invalid_path) != std::string::npos); + } +} + +TEST_CASE( "invalid profile parse message", "[libopenshot][profile]" ) +{ + const std::string invalid_profile = get_temp_test_path("openshot_invalid_profile_for_test.profile"); + { + std::ofstream f(invalid_profile); + f << "width=abc\n"; + } + + try { + openshot::Profile p(invalid_profile); + FAIL("Expected InvalidFile for malformed profile contents"); + } catch (const openshot::InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("Profile file could not be parsed (invalid format or values).") != std::string::npos); + CHECK(message.find(invalid_profile) != std::string::npos); + } +} + TEST_CASE( "24 fps names", "[libopenshot][profile]" ) { std::stringstream path; diff --git a/tests/QtImageReader.cpp b/tests/QtImageReader.cpp index e48839c3..6c39774b 100644 --- a/tests/QtImageReader.cpp +++ b/tests/QtImageReader.cpp @@ -25,8 +25,16 @@ using namespace openshot; TEST_CASE( "Default_Constructor", "[libopenshot][qtimagereader]" ) { - // Check invalid path - CHECK_THROWS_AS(QtImageReader(""), InvalidFile); + // Check invalid path and error details + const std::string invalid_path = "/tmp/__openshot_missing_test_file__.png"; + try { + QtImageReader r(invalid_path); + FAIL("Expected InvalidFile for missing image path"); + } catch (const InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("QtImageReader could not open image file.") != std::string::npos); + CHECK(message.find(invalid_path) != std::string::npos); + } } TEST_CASE( "GetFrame_Before_Opening", "[libopenshot][qtimagereader]" ) diff --git a/tests/Timeline.cpp b/tests/Timeline.cpp index 5fd0e40c..b8c14ba0 100644 --- a/tests/Timeline.cpp +++ b/tests/Timeline.cpp @@ -27,6 +27,7 @@ #include "Frame.h" #include "Fraction.h" #include "effects/Brightness.h" +#include "Exceptions.h" #include "effects/Blur.h" #include "effects/Negate.h" @@ -95,6 +96,19 @@ TEST_CASE( "constructor", "[libopenshot][timeline]" ) CHECK(t2.info.height == 240); } +TEST_CASE( "project constructor invalid path message", "[libopenshot][timeline]" ) +{ + const std::string invalid_path = "/tmp/__openshot_missing_test_project__.osp"; + try { + Timeline t(invalid_path, true); + FAIL("Expected InvalidFile for missing timeline project path"); + } catch (const InvalidFile& e) { + const std::string message = e.what(); + CHECK(message.find("Timeline project file could not be opened.") != std::string::npos); + CHECK(message.find(invalid_path) != std::string::npos); + } +} + TEST_CASE( "Set Json and clear clips", "[libopenshot][timeline]" ) { Fraction fps(30000,1000);