// SPDX-License-Identifier: MIT // // EmulationStation Desktop Edition // ImageComponent.cpp // // Handles images: loading, resizing, cropping, color shifting etc. // #include "components/ImageComponent.h" #include "Log.h" #include "Settings.h" #include "ThemeData.h" #include "Window.h" #include "resources/TextureResource.h" #include "utils/CImgUtil.h" #include "utils/StringUtil.h" glm::ivec2 ImageComponent::getTextureSize() const { if (mTexture) return mTexture->getSize(); else return glm::ivec2 {0, 0}; } glm::vec2 ImageComponent::getSize() const { return GuiComponent::getSize() * (mBottomRightCrop - mTopLeftCrop); } ImageComponent::ImageComponent(bool forceLoad, bool dynamic) : mRenderer {Renderer::getInstance()} , mTargetSize {0, 0} , mFlipX {false} , mFlipY {false} , mTargetIsMax {false} , mScalableNonAspect {false} , mTileWidth {0.0f} , mTileHeight {0.0f} , mColorShift {0xFFFFFFFF} , mColorShiftEnd {0xFFFFFFFF} , mColorGradientHorizontal {true} , mFadeOpacity {0.0f} , mReflectionsFalloff {0.0f} , mFading {false} , mForceLoad {forceLoad} , mDynamic {dynamic} , mRotateByTargetSize {false} , mLinearInterpolation {false} , mMipmapping {false} , mTopLeftCrop {0.0f, 0.0f} , mBottomRightCrop {1.0f, 1.0f} , mClipRegion {0.0f, 0.0f, 0.0f, 0.0f} { updateColors(); } void ImageComponent::resize(bool rasterize) { if (!mTexture) return; const glm::vec2 textureSize {mTexture->getSourceImageSize()}; if (textureSize == glm::vec2 {0.0f, 0.0f}) return; if (mTexture->isTiled()) { mSize = mTargetSize; } else { if (mTargetIsMax) { // Maintain image aspect ratio. mSize = textureSize; glm::vec2 resizeScale {mTargetSize.x / mSize.x, mTargetSize.y / mSize.y}; if (resizeScale.x < resizeScale.y) { // SVG rasterization is determined by height and rasterization is done in terms of // pixels. If rounding is off enough in the rasterization step (for images with // extreme aspect ratios), it can cause cutoff when the aspect ratio breaks. // So we always make sure to round accordingly to avoid such issues. mSize.x *= resizeScale.x; mSize.y = floorf(std::min(mSize.y * resizeScale.x, mTargetSize.y)); } else { // This will be mTargetSize.y(). We can't exceed it. mSize.y *= resizeScale.y; mSize.x = std::min((mSize.y / textureSize.y) * textureSize.x, mTargetSize.x); } } else { // If both axes are set we just stretch or squash, if no axes are set we do nothing. mSize = mTargetSize == glm::vec2 {0.0f, 0.0f} ? textureSize : mTargetSize; // If only one axis is set, we resize in a way that maintains aspect ratio. if (!mTargetSize.x && mTargetSize.y) { mSize.y = mTargetSize.y; mSize.x = (mSize.y / textureSize.y) * textureSize.x; } else if (mTargetSize.x && !mTargetSize.y) { mSize.y = (mTargetSize.x / textureSize.x) * textureSize.y; mSize.x = (mSize.y / textureSize.y) * textureSize.x; } } } // Make sure sub-pixel values are not rounded to zero and that the size is not unreasonably // large (which may be caused by a mistake in the theme configuration). mSize.x = glm::clamp(mSize.x, 1.0f, mRenderer->getScreenWidth() * 3.0f); mSize.y = glm::clamp(mSize.y, 1.0f, mRenderer->getScreenHeight() * 3.0f); if (rasterize) { mTexture->rasterizeAt(mSize.x, mSize.y); onSizeChanged(); } } void ImageComponent::setTileAxes() { if (mTileWidth == 0.0f && mTileHeight == 0.0f) { mTileWidth = mTexture->getSize().x; mTileHeight = mTexture->getSize().y; return; } const float ratio {mTexture->getSourceImageSize().x / mTexture->getSourceImageSize().y}; if (mTileWidth == 0.0f) mTileWidth = std::round(mTileHeight * ratio); else if (mTileHeight == 0.0f) mTileHeight = std::round(mTileWidth / ratio); } void ImageComponent::setImage(const std::string& path, bool tile) { // Always load bundled graphic resources statically, unless mForceLoad has been set. // This eliminates annoying texture pop-in problems that would otherwise occur. if (!mForceLoad && (path[0] == ':') && (path[1] == '/')) { mDynamic = false; } else if (Utils::FileSystem::isDirectory(path)) { LOG(LogError) << "ImageComponent: Path is a directory and not a file: \"" << path << "\""; return; } const bool isScalable { (path != "" && path.length() > 4) ? Utils::String::toLower(path.substr(path.size() - 4, std::string::npos)) == ".svg" : false}; // Create an initial blank texture if needed. if (path.empty() || !ResourceManager::getInstance().fileExists(path)) { if (mDefaultPath.empty() || !ResourceManager::getInstance().fileExists(mDefaultPath)) mTexture.reset(); else mTexture = TextureResource::get(mDefaultPath, tile, mForceLoad, mDynamic, mLinearInterpolation, mMipmapping); resize(true); } else { // For raster images we just load and resize but for SVG images we first need to resize // without rasterizing in order to calculate the correct image size. Then we delete and // reload the texture at the requested size in order to add a valid cache entry. Finally // we perform the actual rasterization to have the cache entry updated with the proper // texture. For SVG images this requires that every call to setImage is made only after // a call to setResize or setMaxSize (so the requested size is known upfront). if (isScalable) { mTexture = TextureResource::get(path, tile, mForceLoad, mDynamic, mLinearInterpolation, mMipmapping, 0, 0, 0.0f, 0.0f); if (tile && (mTileWidth == 0.0f || mTileHeight == 0.0f)) setTileAxes(); resize(false); mTexture.reset(); mTexture = TextureResource::get(path, tile, mForceLoad, mDynamic, mLinearInterpolation, mMipmapping, static_cast(mSize.x), static_cast(mSize.y), mTileWidth, mTileHeight); mTexture->setScalableNonAspect(mScalableNonAspect); mTexture->rasterizeAt(mSize.x, mSize.y); onSizeChanged(); } else { mTexture = TextureResource::get(path, tile, mForceLoad, mDynamic, mLinearInterpolation, mMipmapping, 0, 0, mTileWidth, mTileHeight); if (tile && (mTileWidth == 0.0f || mTileHeight == 0.0f)) setTileAxes(); resize(true); } } } void ImageComponent::setImage(const char* data, size_t length, bool tile) { mTexture.reset(); mTexture = TextureResource::get("", tile); mTexture->initFromMemory(data, length); resize(); } void ImageComponent::setImage(const std::shared_ptr& texture, bool resizeTexture) { mTexture = texture; if (resizeTexture) resize(); } void ImageComponent::setResize(float width, float height) { mTargetSize = glm::vec2 {width, height}; mTargetIsMax = false; resize(); } void ImageComponent::setResize(float width, float height, bool rasterize) { mTargetSize = glm::vec2 {width, height}; mTargetIsMax = false; resize(rasterize); } void ImageComponent::setMaxSize(const float width, const float height) { mTargetSize = glm::vec2 {width, height}; mTargetIsMax = true; resize(); } void ImageComponent::cropLeft(const float percent) { assert(percent >= 0.0f && percent <= 1.0f); mTopLeftCrop.x = percent; } void ImageComponent::cropTop(const float percent) { assert(percent >= 0.0f && percent <= 1.0f); mTopLeftCrop.y = percent; } void ImageComponent::cropRight(const float percent) { assert(percent >= 0.0f && percent <= 1.0f); mBottomRightCrop.x = 1.0f - percent; } void ImageComponent::cropBot(const float percent) { assert(percent >= 0.0f && percent <= 1.0f); mBottomRightCrop.y = 1.0f - percent; } void ImageComponent::crop(const float left, const float top, const float right, const float bot) { cropLeft(left); cropTop(top); cropRight(right); cropBot(bot); } void ImageComponent::uncrop() { // Remove any applied crop. crop(0.0f, 0.0f, 0.0f, 0.0f); } void ImageComponent::cropTransparentPadding(const float maxSizeX, const float maxSizeY) { if (mSize == glm::vec2 {0.0f, 0.0f}) return; std::vector imageRGBA {mTexture.get()->getRawRGBAData()}; if (imageRGBA.size() == 0) return; glm::ivec2 imageSize {mTexture.get()->getSize()}; cimg_library::CImg imageCImg(imageSize.x, imageSize.y, 1, 4, 0); int paddingCoords[4] {0, 0, 0, 0}; // We need to convert our RGBA data to the CImg internal format as CImg does not interleave // the pixels (as in RGBARGBARGBA). Utils::CImg::convertRGBAToCImg(imageRGBA, imageCImg); // This will give us the coordinates for the fully transparent areas. Utils::CImg::getTransparentPaddingCoords(imageCImg, paddingCoords); glm::vec2 originalSize {mSize}; float cropLeft {static_cast(paddingCoords[0]) / static_cast(imageSize.x)}; float cropTop {static_cast(paddingCoords[1]) / static_cast(imageSize.y)}; float cropRight {static_cast(paddingCoords[2]) / static_cast(imageSize.x)}; float cropBottom {static_cast(paddingCoords[3]) / static_cast(imageSize.y)}; crop(cropLeft, cropTop, cropRight, cropBottom); // Cropping the image obviously leads to a reduction in size, so we need to determine // how much to scale up after cropping to keep within the max size restrictions that // were passed as arguments. mSize.x -= mSize.x * (cropLeft + cropRight); mSize.y -= mSize.y * (cropTop + cropBottom); float scaleFactor {originalSize.y / mSize.y}; if (scaleFactor * mSize.x < maxSizeX) scaleFactor = maxSizeX / mSize.x; if (scaleFactor * mSize.y < maxSizeY) scaleFactor = maxSizeY / mSize.y; if (scaleFactor * mSize.x > maxSizeX) scaleFactor = maxSizeX / mSize.x; if (scaleFactor * mSize.y > maxSizeY) scaleFactor = maxSizeY / mSize.y; setResize(mSize.x * scaleFactor, mSize.y * scaleFactor); updateVertices(); } void ImageComponent::setFlipX(bool flip) { mFlipX = flip; updateVertices(); } void ImageComponent::setFlipY(bool flip) { mFlipY = flip; updateVertices(); } void ImageComponent::setColorShift(unsigned int color) { mColorShift = color; mColorShiftEnd = color; updateColors(); } void ImageComponent::setColorShiftEnd(unsigned int color) { mColorShiftEnd = color; updateColors(); } void ImageComponent::setColorGradientHorizontal(bool horizontal) { mColorGradientHorizontal = horizontal; updateColors(); } void ImageComponent::setOpacity(float opacity) { mOpacity = opacity; updateColors(); } void ImageComponent::setSaturation(float saturation) { mSaturation = saturation; updateColors(); } void ImageComponent::setDimming(float dimming) { // Set dimming value. mDimming = dimming; } void ImageComponent::setClipRegion(const glm::vec4& clipRegion) { if (mVertices[0].clipregion == clipRegion) return; mClipRegion = clipRegion; if (mClipRegion == glm::vec4 {0.0f, 0.0f, 0.0f, 0.0f}) { if (mVertices[0].shaderFlags & Renderer::ShaderFlags::CLIPPING) { mVertices[0].shaderFlags ^= Renderer::ShaderFlags::CLIPPING; mVertices[1].shaderFlags ^= Renderer::ShaderFlags::CLIPPING; mVertices[2].shaderFlags ^= Renderer::ShaderFlags::CLIPPING; mVertices[3].shaderFlags ^= Renderer::ShaderFlags::CLIPPING; } } else { mVertices[0].shaderFlags |= Renderer::ShaderFlags::CLIPPING; mVertices[1].shaderFlags |= Renderer::ShaderFlags::CLIPPING; mVertices[2].shaderFlags |= Renderer::ShaderFlags::CLIPPING; mVertices[3].shaderFlags |= Renderer::ShaderFlags::CLIPPING; } mVertices[0].clipregion = clipRegion; mVertices[1].clipregion = clipRegion; mVertices[2].clipregion = clipRegion; mVertices[3].clipregion = clipRegion; } void ImageComponent::updateVertices() { if (!mTexture) return; const glm::vec2 topLeft {0.0f, 0.0f}; const glm::vec2 bottomRight {mSize}; const float px {mTexture->isTiled() ? mSize.x / getTextureSize().x : 1.0f}; const float py {mTexture->isTiled() ? mSize.y / getTextureSize().y : 1.0f}; if (mTileHeight == 0.0f) { // clang-format off mVertices[0] = {{topLeft.x, topLeft.y }, {mTopLeftCrop.x, py - mTopLeftCrop.y }, 0}; mVertices[1] = {{topLeft.x, bottomRight.y}, {mTopLeftCrop.x, 1.0f - mBottomRightCrop.y}, 0}; mVertices[2] = {{bottomRight.x, topLeft.y }, {mBottomRightCrop.x * px, py - mTopLeftCrop.y }, 0}; mVertices[3] = {{bottomRight.x, bottomRight.y}, {mBottomRightCrop.x * px, 1.0f - mBottomRightCrop.y}, 0}; // clang-format on } else { // Adjust the texture size as needed for tiled textures. float px {mSize.x / mTileWidth}; float py {mSize.y / mTileHeight}; // clang-format off mVertices[0] = {{topLeft.x, topLeft.y }, {mTopLeftCrop.x, py - mTopLeftCrop.y }, 0}; mVertices[1] = {{topLeft.x, bottomRight.y}, {mTopLeftCrop.x, 1.0f - mBottomRightCrop.y}, 0}; mVertices[2] = {{bottomRight.x, topLeft.y }, {mBottomRightCrop.x * px, py - mTopLeftCrop.y }, 0}; mVertices[3] = {{bottomRight.x, bottomRight.y}, {mBottomRightCrop.x * px, 1.0f - mBottomRightCrop.y}, 0}; // clang-format on } updateColors(); // We round the vertices already here in this component as we may otherwise end up with edge // cases at sizes near 0.5. for (int i = 0; i < 4; ++i) mVertices[i].position = glm::round(mVertices[i].position); if (mFlipX) { for (int i = 0; i < 4; ++i) mVertices[i].texcoord[0] = px - mVertices[i].texcoord[0]; } if (mFlipY) { for (int i = 0; i < 4; ++i) mVertices[i].texcoord[1] = py - mVertices[i].texcoord[1]; } setClipRegion(mClipRegion); } void ImageComponent::updateColors() { const float opacity {mOpacity * (mFading ? mFadeOpacity : 1.0f)}; const unsigned int color {(mColorShift & 0xFFFFFF00) | static_cast((mColorShift & 0xFF) * opacity)}; const unsigned int colorEnd {(mColorShiftEnd & 0xFFFFFF00) | static_cast((mColorShiftEnd & 0xFF) * opacity)}; mVertices[0].color = color; mVertices[1].color = mColorGradientHorizontal ? color : colorEnd; mVertices[2].color = mColorGradientHorizontal ? colorEnd : color; mVertices[3].color = colorEnd; } void ImageComponent::render(const glm::mat4& parentTrans) { if (!isVisible() || mThemeOpacity == 0.0f || mTexture == nullptr || mTargetSize == glm::vec2 {0.0f, 0.0f} || mSize == glm::vec2 {0.0f, 0.0f}) return; glm::mat4 trans {parentTrans * getTransform()}; mRenderer->setMatrix(trans); if (mTexture && mOpacity > 0.0f) { if (Settings::getInstance()->getBool("DebugImage")) { glm::vec2 targetSizePos {(mTargetSize - mSize) * mOrigin * glm::vec2 {-1.0f}}; mRenderer->drawRect(targetSizePos.x, targetSizePos.y, mTargetSize.x, mTargetSize.y, 0xFF000033, 0xFF000033); if (mClipRegion == glm::vec4 {0.0f, 0.0f, 0.0f, 0.0f}) mRenderer->drawRect(0.0f, 0.0f, mSize.x, mSize.y, 0xFF000033, 0xFF000033); else mRenderer->drawRect(mClipRegion.x, mClipRegion.y, mClipRegion.z - mClipRegion.x, mClipRegion.w - mClipRegion.y, 0xFF000033, 0xFF000033); } // An image with zero size would normally indicate a corrupt image file. if (mTexture->getSize() != glm::ivec2 {}) { // Actually draw the image. // The bind() function returns false if the texture is not currently loaded. A blank // texture is bound in this case but we want to handle a fade so it doesn't just // 'jump' in when it finally loads. The exception is if the cached background is // getting invalidated, in which case we want to make sure to not get a partially // faded texture rendered onto the new background. if (mWindow->isInvalidatingCachedBackground()) mTexture->bind(); else fadeIn(mTexture->bind()); mVertices->saturation = mSaturation * mThemeSaturation; mVertices->opacity = mThemeOpacity; mVertices->dimming = mDimming; mVertices->reflectionsFalloff = mReflectionsFalloff; mRenderer->drawTriangleStrips(&mVertices[0], 4); } else { if (!mTexture) { LOG(LogError) << "Image texture is not initialized"; } else { std::string textureFilePath = mTexture->getTextureFilePath(); if (textureFilePath != "") { LOG(LogError) << "Image texture for file \"" << textureFilePath << "\" has zero size"; } else { LOG(LogError) << "Image texture has zero size"; } } mTexture.reset(); } } GuiComponent::renderChildren(trans); } void ImageComponent::fadeIn(bool textureLoaded) { if (!mForceLoad) { if (!textureLoaded) { // Start the fade if this is the first time we've encountered the unloaded texture. if (!mFading) { // Start with a zero opacity and flag it as fading. mFadeOpacity = 0.0f; mFading = true; updateColors(); } } else if (mFading) { // The texture is loaded and we need to fade it in. The fade is based on the frame // rate and is 1/4 second if running at 60 frames per second although the actual // value is not that important. float opacity {mFadeOpacity + 1.0f / 15.0f}; // See if we've finished fading. if (opacity >= 1.0f) { mFadeOpacity = 1.0f; mFading = false; } else { mFadeOpacity = opacity; } updateColors(); } } } void ImageComponent::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) | ((properties & (ThemeFlags::SIZE | POSITION)) ? ORIGIN : 0)); const ThemeData::ThemeElement* elem {theme->getElement(view, element, "image")}; if (!elem) return; glm::vec2 scale {getParent() ? getParent()->getSize() : glm::vec2(Renderer::getScreenWidth(), Renderer::getScreenHeight())}; bool noMax {false}; if (properties & ThemeFlags::SIZE) { if (elem->has("size")) { glm::vec2 imageSize {elem->get("size")}; if (imageSize == glm::vec2 {0.0f, 0.0f}) { LOG(LogWarning) << "ImageComponent: Invalid theme configuration, property " "for element \"" << element.substr(6) << "\" is set to zero"; imageSize = {0.001f, 0.001f}; } if (imageSize.x > 0.0f) imageSize.x = glm::clamp(imageSize.x, 0.001f, 3.0f); if (imageSize.y > 0.0f) imageSize.y = glm::clamp(imageSize.y, 0.001f, 3.0f); setResize(imageSize * scale); if (imageSize.x != 0.0f && imageSize.y != 0.0f) noMax = true; } else if (elem->has("maxSize")) { glm::vec2 imageMaxSize {elem->get("maxSize")}; imageMaxSize.x = glm::clamp(imageMaxSize.x, 0.001f, 3.0f); imageMaxSize.y = glm::clamp(imageMaxSize.y, 0.001f, 3.0f); setMaxSize(imageMaxSize * scale); } } if (elem->has("interpolation")) { const std::string interpolation {elem->get("interpolation")}; if (interpolation == "linear") { mLinearInterpolation = true; } else if (interpolation == "nearest") { mLinearInterpolation = false; } else { mLinearInterpolation = false; LOG(LogWarning) << "ImageComponent: Invalid theme configuration, property " " defined as \"" << interpolation << "\""; } } if (elem->has("default")) setDefaultImage(elem->get("default")); if (properties & PATH && elem->has("path")) { bool tile {elem->has("tile") && elem->get("tile")}; const std::string path {elem->get("path")}; if (!tile && !theme->isLegacyTheme() && noMax && path.length() > 4 && Utils::String::toLower(path.substr(path.size() - 4, std::string::npos)) == ".svg") { mScalableNonAspect = true; } if (tile && elem->has("tileSize")) { glm::vec2 tileSize {elem->get("tileSize")}; if (tileSize.x == 0.0f && tileSize.y == 0.0f) { LOG(LogWarning) << "ImageComponent: Invalid theme configuration, property " "for element \"" << element.substr(6) << "\" is set to zero"; tile = false; } else { tileSize.x = glm::clamp(tileSize.x, 0.0f, 1.0f); tileSize.y = glm::clamp(tileSize.y, 0.0f, 1.0f); mTileWidth = tileSize.x * scale.x; mTileHeight = tileSize.y * scale.y; if (mTileWidth != 0.0f && mTileHeight != 0.0f) mScalableNonAspect = true; } } setImage(path, tile); } if (properties && elem->has("imageType")) { std::string imageTypes {elem->get("imageType")}; for (auto& character : imageTypes) { if (std::isspace(character)) character = ','; } imageTypes = Utils::String::replace(imageTypes, ",,", ","); mThemeImageTypes = Utils::String::delimitedStringToVector(imageTypes, ","); if (mThemeImageTypes.empty()) { LOG(LogError) << "ImageComponent: Invalid theme configuration, property " "contains no values"; } for (std::string& type : mThemeImageTypes) { if (std::find(supportedImageTypes.cbegin(), supportedImageTypes.cend(), type) == supportedImageTypes.cend()) { LOG(LogError) << "ImageComponent: Invalid theme configuration, property " "defined as \"" << type << "\""; mThemeImageTypes.clear(); break; } } std::vector sortedTypes {mThemeImageTypes}; std::stable_sort(sortedTypes.begin(), sortedTypes.end()); if (std::adjacent_find(sortedTypes.begin(), sortedTypes.end()) != sortedTypes.end()) { LOG(LogError) << "ImageComponent: Invalid theme configuration, property " "contains duplicate values"; mThemeImageTypes.clear(); } } if (elem->has("metadataElement") && elem->get("metadataElement")) mComponentThemeFlags |= ComponentThemeFlags::METADATA_ELEMENT; if (properties & COLOR) { if (elem->has("color")) setColorShift(elem->get("color")); if (elem->has("colorEnd")) setColorShiftEnd(elem->get("colorEnd")); if (elem->has("gradientType")) { const std::string gradientType {elem->get("gradientType")}; if (gradientType == "horizontal") { setColorGradientHorizontal(true); } else if (gradientType == "vertical") { setColorGradientHorizontal(false); } else { setColorGradientHorizontal(true); LOG(LogWarning) << "ImageComponent: Invalid theme configuration, property " " defined as \"" << gradientType << "\""; } } } if (elem->has("scrollFadeIn") && elem->get("scrollFadeIn")) mComponentThemeFlags |= ComponentThemeFlags::SCROLL_FADE_IN; } std::vector ImageComponent::getHelpPrompts() { std::vector ret; ret.push_back(HelpPrompt("a", "select")); return ret; }