// SPDX-License-Identifier: MIT // // ES-DE Frontend // GIFAnimComponent.cpp // // Component to play GIF animations. // #define DEBUG_ANIMATION false #if defined(_MSC_VER) // MSVC compiler. #define _CRT_SECURE_NO_WARNINGS #endif #include "components/GIFAnimComponent.h" #include "Log.h" #include "Window.h" #include "resources/ResourceManager.h" #include "utils/StringUtil.h" GIFAnimComponent::GIFAnimComponent() : mRenderer {Renderer::getInstance()} , mTargetSize {0.0f, 0.0f} , mFrameSize {0} , mAnimFile {nullptr} , mAnimation {nullptr} , mFrame {nullptr} , mStartDirection {"normal"} , mTotalFrames {0} , mFrameNum {0} , mFrameTime {0} , mFileWidth {0} , mFileHeight {0} , mFrameRate {0.0} , mSpeedModifier {1.0f} , mTargetPacing {0} , mTimeAccumulator {0} , mLastRenderedFrame {-1} , mSkippedFrames {0} , mHoldFrame {true} , mPause {false} , mExternalPause {false} , mAlternate {false} , mIterationCount {0} , mPlayCount {0} , mTargetIsMax {false} , mCornerRadius {0.0f} , mColorShift {0xFFFFFFFF} , mColorShiftEnd {0xFFFFFFFF} , mColorGradientHorizontal {true} { // Get an empty texture for rendering the animation. mTexture = TextureResource::get(""); mAnimIO.read_proc = readProc; mAnimIO.write_proc = writeProc; mAnimIO.seek_proc = seekProc; mAnimIO.tell_proc = tellProc; // Set component defaults. setSize(Renderer::getScreenWidth() * 0.2f, Renderer::getScreenHeight() * 0.2f); setPosition(Renderer::getScreenWidth() * 0.3f, Renderer::getScreenHeight() * 0.3f); setDefaultZIndex(35.0f); setZIndex(35.0f); mTexture->setLinearMagnify(false); } GIFAnimComponent::~GIFAnimComponent() { if (mAnimFile != nullptr) { fclose(mAnimFile); mAnimFile = nullptr; } } void GIFAnimComponent::setAnimation(const std::string& path) { if (mAnimation != nullptr) { FreeImage_CloseMultiBitmap(mAnimation, 0); mAnimation = nullptr; mPictureRGBA.clear(); mLastRenderedFrame = -1; mFileWidth = 0; mFileHeight = 0; } mPath = path; if (mPath.empty()) { LOG(LogError) << "Path to GIF animation is empty"; return; } if (mPath.front() == ':') mPath = ResourceManager::getInstance().getResourcePath(mPath); else mPath = Utils::FileSystem::expandHomePath(mPath); if (!(Utils::FileSystem::isRegularFile(mPath) || Utils::FileSystem::isSymlink(mPath))) { LOG(LogError) << "Couldn't open GIF animation file \"" << mPath << "\""; return; } FREE_IMAGE_FORMAT fileFormat; #if defined(_WIN64) fileFormat = FreeImage_GetFileTypeU(Utils::String::stringToWideString(mPath).c_str()); #else fileFormat = FreeImage_GetFileType(mPath.c_str()); #endif if (fileFormat == FIF_UNKNOWN) #if defined(_WIN64) fileFormat = FreeImage_GetFIFFromFilenameU(Utils::String::stringToWideString(mPath).c_str()); #else fileFormat = FreeImage_GetFIFFromFilename(mPath.c_str()); #endif if (fileFormat != FIF_GIF) { LOG(LogError) << "GIFAnimComponent::setAnimation(): Image not recognized as being in GIF format"; return; } // Make sure that we can actually read this format. if (FreeImage_FIFSupportsReading(fileFormat)) { #if defined(_WIN64) mAnimFile = _wfopen(Utils::String::stringToWideString(mPath).c_str(), L"r+b"); #else mAnimFile = fopen(mPath.c_str(), "r+b"); #endif if (mAnimFile != nullptr) mAnimation = FreeImage_OpenMultiBitmapFromHandle( fileFormat, &mAnimIO, static_cast(mAnimFile), GIF_PLAYBACK); mFrame = FreeImage_LockPage(mAnimation, 0); FITAG* tagFrameTime {nullptr}; FreeImage_GetMetadata(FIMD_ANIMATION, mFrame, "FrameTime", &tagFrameTime); if (tagFrameTime != nullptr) { if (FreeImage_GetTagCount(tagFrameTime) == 1) { const uint32_t frameTime { *static_cast(FreeImage_GetTagValue(tagFrameTime))}; if (frameTime >= 20 && frameTime <= 1000) mFrameTime = frameTime; } } } else { LOG(LogError) << "GIFAnimComponent::setAnimation(): Couldn't process file \"" << mPath << "\""; return; } if (!mAnimation) { LOG(LogError) << "GIFAnimComponent::setAnimation(): Couldn't load animation file \"" << mPath << "\""; return; } size_t width {0}; size_t height {0}; unsigned int filePitch {0}; mTotalFrames = static_cast(FreeImage_GetPageCount(mAnimation)); mFileWidth = FreeImage_GetWidth(mFrame); mFileHeight = FreeImage_GetHeight(mFrame); filePitch = FreeImage_GetPitch(mFrame); if (mTargetIsMax || mSize.x == 0.0f || mSize.y == 0.0f) { const double sizeRatio {static_cast(mFileWidth) / static_cast(mFileHeight)}; if (mTargetIsMax) { // Just a precaution if FreeImage would return zero for some reason. if (mFileWidth == 0) mFileWidth = 1; if (mFileHeight == 0) mFileHeight = 1; mSize.x = static_cast(mFileWidth); mSize.y = static_cast(mFileHeight); // Preserve aspect ratio. const glm::vec2 resizeScale {mTargetSize.x / mSize.x, mTargetSize.y / mSize.y}; if (resizeScale.x < resizeScale.y) { mSize.x *= resizeScale.x; mSize.y = std::min(mSize.y * resizeScale.x, mTargetSize.y); } else { mSize.y *= resizeScale.y; mSize.x = std::min((mSize.y / static_cast(mFileHeight)) * static_cast(mFileWidth), mTargetSize.x); } width = static_cast(mSize.x); height = static_cast(mSize.y); } else if (mSize.x == 0) { width = static_cast(static_cast(mSize.y) * sizeRatio); height = static_cast(mSize.y); } else { width = static_cast(mSize.x); height = static_cast(static_cast(mSize.x) / sizeRatio); } } else { width = static_cast(mSize.x); height = static_cast(mSize.y); } mSize.x = static_cast(width); mSize.y = static_cast(height); if (!mTargetIsMax) mTargetSize = mSize; FreeImage_PreMultiplyWithAlpha(mFrame); mPictureRGBA.resize(mFileWidth * mFileHeight * 4); FreeImage_ConvertToRawBits(reinterpret_cast(&mPictureRGBA.at(0)), mFrame, filePitch, 32, FI_RGBA_BLUE, FI_RGBA_GREEN, FI_RGBA_RED, 1); mTexture->initFromPixels(&mPictureRGBA.at(0), mFileWidth, mFileHeight); FreeImage_UnlockPage(mAnimation, mFrame, false); FreeImage_CloseMultiBitmap(mAnimation, 0); mDirection = mStartDirection; mFrameRate = 1000.0 / static_cast(mFrameTime); mFrameSize = mFileWidth * mFileHeight * 4; mTargetPacing = static_cast((1000.0 / mFrameRate) / static_cast(mSpeedModifier)); if (mDirection == "reverse") mFrameNum = mTotalFrames - 1; if (DEBUG_ANIMATION) { const int duration {mTargetPacing * mTotalFrames}; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Width: " << mFileWidth; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Height: " << mFileHeight; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Total number of frames: " << mTotalFrames; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Frame rate: " << mFrameRate; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Speed modifier: " << mSpeedModifier; // This figure does not double if direction has been set to alternate or alternateReverse, // it only tells the duration of a single playthrough of all frames. LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Target duration: " << duration << " ms"; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Frame size: " << mFrameSize << " bytes (" << std::fixed << std::setprecision(1) << static_cast(mFrameSize) / 1024.0 / 1024.0 << " MiB)"; LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Animation size: " << mFrameSize * mTotalFrames << " bytes (" << std::fixed << std::setprecision(1) << static_cast(mFrameSize * mTotalFrames) / 1024.0 / 1024.0 << " MiB)"; } mAnimationStartTime = std::chrono::system_clock::now(); } void GIFAnimComponent::resetComponent() { mExternalPause = false; mPlayCount = 0; mTimeAccumulator = 0; mDirection = mStartDirection; mFrameNum = mStartDirection == "reverse" ? mTotalFrames - 1 : 0; if (mAnimation != nullptr) mLastRenderedFrame = static_cast(mFrameNum); } void GIFAnimComponent::onSizeChanged() { // Setting the animation again will completely reinitialize it. if (mPath != "") setAnimation(mPath); } void GIFAnimComponent::applyTheme(const std::shared_ptr& theme, const std::string& view, const std::string& element, unsigned int properties) { using namespace ThemeFlags; GuiComponent::applyTheme(theme, view, element, properties ^ ThemeFlags::SIZE); const ThemeData::ThemeElement* elem {theme->getElement(view, element, "animation")}; if (!elem) return; const glm::vec2 scale {glm::vec2(Renderer::getScreenWidth(), Renderer::getScreenHeight())}; if (elem->has("size")) { glm::vec2 animationSize {elem->get("size")}; if (animationSize == glm::vec2 {0.0f, 0.0f}) { LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, property " "\"size\" for element \"" << element.substr(10) << "\" is set to zero"; animationSize = {0.01f, 0.01f}; } if (animationSize.x > 0.0f) animationSize.x = glm::clamp(animationSize.x, 0.01f, 1.0f); if (animationSize.y > 0.0f) animationSize.y = glm::clamp(animationSize.y, 0.01f, 1.0f); setSize(animationSize * scale); } else if (elem->has("maxSize")) { const glm::vec2 animationMaxSize {glm::clamp(elem->get("maxSize"), 0.01f, 1.0f)}; setSize(animationMaxSize * scale); mTargetIsMax = true; mTargetSize = mSize; } if (properties & ThemeFlags::POSITION && elem->has("stationary")) { const std::string& stationary {elem->get("stationary")}; if (stationary == "never") mStationary = Stationary::NEVER; else if (stationary == "always") mStationary = Stationary::ALWAYS; else if (stationary == "withinView") mStationary = Stationary::WITHIN_VIEW; else if (stationary == "betweenViews") mStationary = Stationary::BETWEEN_VIEWS; else LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, property " "\"stationary\" for element \"" << element.substr(10) << "\" defined as \"" << stationary << "\""; } if (elem->has("metadataElement") && elem->get("metadataElement")) mComponentThemeFlags |= ComponentThemeFlags::METADATA_ELEMENT; if (elem->has("speed")) mSpeedModifier = glm::clamp(elem->get("speed"), 0.2f, 3.0f); if (elem->has("direction")) { const std::string& direction {elem->get("direction")}; if (direction == "normal") { mStartDirection = "normal"; mAlternate = false; } else if (direction == "reverse") { mStartDirection = "reverse"; mAlternate = false; } else if (direction == "alternate") { mStartDirection = "normal"; mAlternate = true; } else if (direction == "alternateReverse") { mStartDirection = "reverse"; mAlternate = true; } else { LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, property " "\"direction\" for element \"" << element.substr(10) << "\" defined as \"" << direction << "\""; mStartDirection = "normal"; mAlternate = false; } } if (elem->has("iterationCount")) { mIterationCount = glm::clamp(elem->get("iterationCount"), 0u, 10u); if (mAlternate) mIterationCount *= 2; } // Enable linear interpolation by default if element is arbitrarily rotated. if (properties & ThemeFlags::ROTATION && elem->has("rotation")) { const float rotation {std::abs(elem->get("rotation"))}; if (rotation != 0.0f && (std::round(rotation) != rotation || static_cast(rotation) % 90 != 0)) mTexture->setLinearMagnify(true); } if (elem->has("interpolation")) { const std::string& interpolation {elem->get("interpolation")}; if (interpolation == "linear") { mTexture->setLinearMagnify(true); } else if (interpolation == "nearest") { mTexture->setLinearMagnify(false); } else { LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, property " "\"interpolation\" for element \"" << element.substr(10) << "\" defined as \"" << interpolation << "\""; } } if (elem->has("cornerRadius")) mCornerRadius = glm::clamp(elem->get("cornerRadius"), 0.0f, 0.5f) * mRenderer->getScreenWidth(); if (properties & COLOR) { if (elem->has("color")) { mColorShift = elem->get("color"); mColorShiftEnd = mColorShift; } if (elem->has("colorEnd")) mColorShiftEnd = elem->get("colorEnd"); if (elem->has("gradientType")) { const std::string& gradientType {elem->get("gradientType")}; if (gradientType == "horizontal") { mColorGradientHorizontal = true; } else if (gradientType == "vertical") { mColorGradientHorizontal = false; } else { mColorGradientHorizontal = true; LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, property " "\"gradientType\" for element \"" << element.substr(10) << "\" defined as \"" << gradientType << "\""; } } } if (elem->has("path")) setAnimation(elem->get("path")); } void GIFAnimComponent::update(int deltaTime) { if (mAnimation == nullptr || !isVisible() || mOpacity == 0.0f || mThemeOpacity == 0.0f) return; if (mWindow->getAllowFileAnimation()) { mPause = false; } else { mPause = true; mTimeAccumulator = 0; return; } // Make sure no frames are advanced unless update() has been called. mHoldFrame = false; // If the time accumulator value is really high something must have happened such as the // application having been suspended. Reset it to zero in this case as it would otherwise // never recover. if (mTimeAccumulator > deltaTime * 200) mTimeAccumulator = 0; // Prevent animation from playing too quickly. if (mTimeAccumulator + deltaTime < mTargetPacing) { mHoldFrame = true; mTimeAccumulator += deltaTime; } else { mHoldFrame = false; mTimeAccumulator = mTimeAccumulator - mTargetPacing + deltaTime; } // Rudimentary frame skipping logic, not entirely accurate but probably good enough. while (mTimeAccumulator - deltaTime > mTargetPacing) { if (DEBUG_ANIMATION && 0) { LOG(LogDebug) << "GIFAnimComponent::update(): Skipped Frame, mTimeAccumulator / " "mTargetPacing: " << mTimeAccumulator - deltaTime << " / " << mTargetPacing; } if (mDirection == "reverse") --mFrameNum; else ++mFrameNum; ++mSkippedFrames; mTimeAccumulator -= mTargetPacing; } } void GIFAnimComponent::render(const glm::mat4& parentTrans) { if (mAnimation == nullptr || !isVisible() || mOpacity == 0.0f || mThemeOpacity == 0.0f) return; glm::mat4 trans {parentTrans * getTransform()}; // This is necessary as there may otherwise be no texture to render when paused. if ((mExternalPause || mPause) && mTexture->getSize().x == 0.0f) { mTexture->initFromPixels(&mPictureRGBA.at(0), static_cast(mSize.x), static_cast(mSize.y)); } bool doRender {true}; // Don't render if a menu is open except if the cached background is getting invalidated. if (mWindow->getGuiStackSize() > 1 && !mWindow->isInvalidatingCachedBackground()) doRender = false; // Don't render any new frames if paused or if a menu is open (unless invalidating background). if ((!mPause && !mExternalPause) && doRender) { if ((mDirection == "normal" && mFrameNum >= mTotalFrames) || (mDirection == "reverse" && mFrameNum < 0)) { if (DEBUG_ANIMATION) { LOG(LogDebug) << "GIFAnimComponent::render(): Skipped frames: " << mSkippedFrames; LOG(LogDebug) << "GIFAnimComponent::render(): Actual duration: " << std::chrono::duration_cast( std::chrono::system_clock::now() - mAnimationStartTime) .count() << " ms"; } if (mAlternate) { if (mDirection == "normal") mDirection = "reverse"; else mDirection = "normal"; } mTimeAccumulator = 0; mSkippedFrames = 0; ++mPlayCount; if (mDirection == "reverse" && mAlternate) mFrameNum = mTotalFrames - 2; else if (mDirection == "reverse" && !mAlternate) mFrameNum = mTotalFrames - 1; else if (mDirection == "normal" && mAlternate) mFrameNum = 1; else mFrameNum = 0; if (mIterationCount != 0 && mPlayCount >= mIterationCount) { mPlayCount = 0; mExternalPause = true; mFrameNum = mTotalFrames; } if (DEBUG_ANIMATION) mAnimationStartTime = std::chrono::system_clock::now(); } if (!mHoldFrame) { mAnimation = FreeImage_OpenMultiBitmapFromHandle( FIF_GIF, &mAnimIO, static_cast(mAnimFile), GIF_PLAYBACK); mFrame = FreeImage_LockPage(mAnimation, mFrameNum); FreeImage_PreMultiplyWithAlpha(mFrame); mPictureRGBA.clear(); mPictureRGBA.resize(mFrameSize); FreeImage_ConvertToRawBits(reinterpret_cast(&mPictureRGBA.at(0)), mFrame, FreeImage_GetPitch(mFrame), 32, FI_RGBA_RED, FI_RGBA_GREEN, FI_RGBA_BLUE, 1); mTexture->initFromPixels(&mPictureRGBA.at(0), mFileWidth, mFileHeight); FreeImage_UnlockPage(mAnimation, mFrame, false); FreeImage_CloseMultiBitmap(mAnimation, 0); if (mDirection == "reverse") --mFrameNum; else ++mFrameNum; } } mRenderer->setMatrix(trans); if (Settings::getInstance()->getBool("DebugImage")) { if (mTargetIsMax) { const glm::vec2 targetSizePos { glm::round((mTargetSize - mSize) * mOrigin * glm::vec2 {-1.0f})}; mRenderer->drawRect(targetSizePos.x, targetSizePos.y, mTargetSize.x, mTargetSize.y, 0xFF000033, 0xFF000033); } mRenderer->drawRect(0.0f, 0.0f, mSize.x, mSize.y, 0xFF000033, 0xFF000033); } if (mTexture->getSize().x != 0.0f) { mTexture->bind(0); Renderer::Vertex vertices[4]; // clang-format off vertices[0] = {{0.0f, 0.0f }, {0.0f, 0.0f}, mColorShift}; vertices[1] = {{0.0f, mSize.y}, {0.0f, 1.0f}, mColorGradientHorizontal ? mColorShift : mColorShiftEnd}; vertices[2] = {{mSize.x, 0.0f }, {1.0f, 0.0f}, mColorGradientHorizontal ? mColorShiftEnd : mColorShift}; vertices[3] = {{mSize.x, mSize.y}, {1.0f, 1.0f}, mColorShiftEnd}; // clang-format on // Round vertices. for (int i {0}; i < 4; ++i) vertices[i].position = glm::round(vertices[i].position); vertices->brightness = mBrightness; vertices->saturation = mSaturation * mThemeSaturation; vertices->opacity = mOpacity * mThemeOpacity; vertices->dimming = mDimming; vertices->shaderFlags = Renderer::ShaderFlags::PREMULTIPLIED; if (mCornerRadius > 0.0f) { vertices->cornerRadius = mCornerRadius; vertices->shaderFlags = vertices->shaderFlags | Renderer::ShaderFlags::ROUNDED_CORNERS; } // Render it. mRenderer->drawTriangleStrips(&vertices[0], 4); } mHoldFrame = true; }