Add HEIF/AVIF EXIF metadata extraction and UI support (#44466)

- Support EXIF extraction from HEIF/HEIC and AVIF images by detecting
container format and using correct WIC metadata paths.
- Extend supported file extensions to include .heic, .heif, and .avif.
- Add unit tests and test data for HEIF/AVIF extraction, with graceful
handling if required Windows Store extensions are missing.
- Update PowerRename settings UI to show HEIF/AVIF extension status and
provide install buttons.
- Extend ViewModel to detect/install required extensions and expose
status for binding.

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #43758
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
moooyo
2026-01-08 11:18:47 +08:00
committed by GitHub
parent 9c58574484
commit 72fc8288eb
12 changed files with 724 additions and 46 deletions

View File

@@ -273,3 +273,6 @@ St&yle
# Usernames with numbers # Usernames with numbers
# 0x6f677548 is user name but user folder causes a flag # 0x6f677548 is user name but user folder causes a flag
\bx6f677548\b \bx6f677548\b
# Microsoft Store URLs and product IDs
ms-windows-store://\S+

View File

@@ -349,12 +349,17 @@ bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataTyp
// - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding // - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding
// - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding
// - PNG (text chunks) // - PNG (text chunks)
// - HEIF/HEIC (IFD, Exif, XMP, GPS) - requires HEIF Image Extensions from Microsoft Store
// - AVIF (IFD, Exif, XMP, GPS) - requires AV1 Video Extension from Microsoft Store
static const std::unordered_set<std::wstring> supportedExtensions = { static const std::unordered_set<std::wstring> supportedExtensions = {
L".jpg", L".jpg",
L".jpeg", L".jpeg",
L".png", L".png",
L".tif", L".tif",
L".tiff" L".tiff",
L".heic",
L".heif",
L".avif"
}; };
// If file type doesn't support metadata, no need to check patterns // If file type doesn't support metadata, no need to check patterns

View File

@@ -38,7 +38,7 @@ namespace
const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist
const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright
// GPS paths // GPS paths for JPEG format
const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude
const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef
const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude
@@ -46,6 +46,35 @@ namespace
const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude
const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef
// WIC metadata property paths for TIFF/HEIF format (uses /ifd prefix directly)
// HEIF/HEIC images use TIFF-style metadata paths
const std::wstring HEIF_DATE_TAKEN = L"/ifd/exif/{ushort=36867}"; // DateTimeOriginal
const std::wstring HEIF_DATE_DIGITIZED = L"/ifd/exif/{ushort=36868}"; // DateTimeDigitized
const std::wstring HEIF_DATE_MODIFIED = L"/ifd/{ushort=306}"; // DateTime
const std::wstring HEIF_CAMERA_MAKE = L"/ifd/{ushort=271}"; // Make
const std::wstring HEIF_CAMERA_MODEL = L"/ifd/{ushort=272}"; // Model
const std::wstring HEIF_LENS_MODEL = L"/ifd/exif/{ushort=42036}"; // LensModel
const std::wstring HEIF_ISO = L"/ifd/exif/{ushort=34855}"; // ISOSpeedRatings
const std::wstring HEIF_APERTURE = L"/ifd/exif/{ushort=33437}"; // FNumber
const std::wstring HEIF_SHUTTER_SPEED = L"/ifd/exif/{ushort=33434}"; // ExposureTime
const std::wstring HEIF_FOCAL_LENGTH = L"/ifd/exif/{ushort=37386}"; // FocalLength
const std::wstring HEIF_EXPOSURE_BIAS = L"/ifd/exif/{ushort=37380}"; // ExposureBiasValue
const std::wstring HEIF_FLASH = L"/ifd/exif/{ushort=37385}"; // Flash
const std::wstring HEIF_ORIENTATION = L"/ifd/{ushort=274}"; // Orientation
const std::wstring HEIF_COLOR_SPACE = L"/ifd/exif/{ushort=40961}"; // ColorSpace
const std::wstring HEIF_WIDTH = L"/ifd/exif/{ushort=40962}"; // PixelXDimension
const std::wstring HEIF_HEIGHT = L"/ifd/exif/{ushort=40963}"; // PixelYDimension
const std::wstring HEIF_ARTIST = L"/ifd/{ushort=315}"; // Artist
const std::wstring HEIF_COPYRIGHT = L"/ifd/{ushort=33432}"; // Copyright
// GPS paths for TIFF/HEIF format
const std::wstring HEIF_GPS_LATITUDE = L"/ifd/gps/{ushort=2}"; // GPSLatitude
const std::wstring HEIF_GPS_LATITUDE_REF = L"/ifd/gps/{ushort=1}"; // GPSLatitudeRef
const std::wstring HEIF_GPS_LONGITUDE = L"/ifd/gps/{ushort=4}"; // GPSLongitude
const std::wstring HEIF_GPS_LONGITUDE_REF = L"/ifd/gps/{ushort=3}"; // GPSLongitudeRef
const std::wstring HEIF_GPS_ALTITUDE = L"/ifd/gps/{ushort=6}"; // GPSAltitude
const std::wstring HEIF_GPS_ALTITUDE_REF = L"/ifd/gps/{ushort=5}"; // GPSAltitudeRef
// Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ // Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/
// Based on actual WIC path format discovered through enumeration // Based on actual WIC path format discovered through enumeration
@@ -465,8 +494,11 @@ bool WICMetadataExtractor::LoadEXIFMetadata(
return false; return false;
} }
ExtractAllEXIFFields(reader, outMetadata); // Detect container format to determine correct metadata paths
ExtractGPSData(reader, outMetadata); MetadataPathFormat pathFormat = GetMetadataPathFormatFromDecoder(decoder);
ExtractAllEXIFFields(reader, outMetadata, pathFormat);
ExtractGPSData(reader, outMetadata, pathFormat);
return true; return true;
} }
@@ -520,51 +552,113 @@ CComPtr<IWICMetadataQueryReader> WICMetadataExtractor::GetMetadataReader(IWICBit
return reader; return reader;
} }
void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) MetadataPathFormat WICMetadataExtractor::GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder)
{
if (!decoder)
{
return MetadataPathFormat::JPEG;
}
GUID containerFormat;
if (SUCCEEDED(decoder->GetContainerFormat(&containerFormat)))
{
// HEIF and TIFF use /ifd/... paths directly
if (containerFormat == GUID_ContainerFormatHeif ||
containerFormat == GUID_ContainerFormatTiff)
{
return MetadataPathFormat::IFD;
}
}
// JPEG and other formats use /app1/ifd/... paths
return MetadataPathFormat::JPEG;
}
void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat)
{ {
if (!reader) if (!reader)
return; return;
// Select the correct paths based on container format
const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD);
// Date/time paths
const auto& dateTakenPath = useIfdPaths ? HEIF_DATE_TAKEN : EXIF_DATE_TAKEN;
const auto& dateDigitizedPath = useIfdPaths ? HEIF_DATE_DIGITIZED : EXIF_DATE_DIGITIZED;
const auto& dateModifiedPath = useIfdPaths ? HEIF_DATE_MODIFIED : EXIF_DATE_MODIFIED;
// Camera info paths
const auto& cameraMakePath = useIfdPaths ? HEIF_CAMERA_MAKE : EXIF_CAMERA_MAKE;
const auto& cameraModelPath = useIfdPaths ? HEIF_CAMERA_MODEL : EXIF_CAMERA_MODEL;
const auto& lensModelPath = useIfdPaths ? HEIF_LENS_MODEL : EXIF_LENS_MODEL;
// Shooting parameter paths
const auto& isoPath = useIfdPaths ? HEIF_ISO : EXIF_ISO;
const auto& aperturePath = useIfdPaths ? HEIF_APERTURE : EXIF_APERTURE;
const auto& shutterSpeedPath = useIfdPaths ? HEIF_SHUTTER_SPEED : EXIF_SHUTTER_SPEED;
const auto& focalLengthPath = useIfdPaths ? HEIF_FOCAL_LENGTH : EXIF_FOCAL_LENGTH;
const auto& exposureBiasPath = useIfdPaths ? HEIF_EXPOSURE_BIAS : EXIF_EXPOSURE_BIAS;
const auto& flashPath = useIfdPaths ? HEIF_FLASH : EXIF_FLASH;
// Image property paths
const auto& widthPath = useIfdPaths ? HEIF_WIDTH : EXIF_WIDTH;
const auto& heightPath = useIfdPaths ? HEIF_HEIGHT : EXIF_HEIGHT;
const auto& orientationPath = useIfdPaths ? HEIF_ORIENTATION : EXIF_ORIENTATION;
const auto& colorSpacePath = useIfdPaths ? HEIF_COLOR_SPACE : EXIF_COLOR_SPACE;
// Author info paths
const auto& artistPath = useIfdPaths ? HEIF_ARTIST : EXIF_ARTIST;
const auto& copyrightPath = useIfdPaths ? HEIF_COPYRIGHT : EXIF_COPYRIGHT;
// Extract date/time fields // Extract date/time fields
metadata.dateTaken = ReadDateTime(reader, EXIF_DATE_TAKEN); metadata.dateTaken = ReadDateTime(reader, dateTakenPath);
metadata.dateDigitized = ReadDateTime(reader, EXIF_DATE_DIGITIZED); metadata.dateDigitized = ReadDateTime(reader, dateDigitizedPath);
metadata.dateModified = ReadDateTime(reader, EXIF_DATE_MODIFIED); metadata.dateModified = ReadDateTime(reader, dateModifiedPath);
// Extract camera information // Extract camera information
metadata.cameraMake = ReadString(reader, EXIF_CAMERA_MAKE); metadata.cameraMake = ReadString(reader, cameraMakePath);
metadata.cameraModel = ReadString(reader, EXIF_CAMERA_MODEL); metadata.cameraModel = ReadString(reader, cameraModelPath);
metadata.lensModel = ReadString(reader, EXIF_LENS_MODEL); metadata.lensModel = ReadString(reader, lensModelPath);
// Extract shooting parameters // Extract shooting parameters
metadata.iso = ReadInteger(reader, EXIF_ISO); metadata.iso = ReadInteger(reader, isoPath);
metadata.aperture = ReadDouble(reader, EXIF_APERTURE); metadata.aperture = ReadDouble(reader, aperturePath);
metadata.shutterSpeed = ReadDouble(reader, EXIF_SHUTTER_SPEED); metadata.shutterSpeed = ReadDouble(reader, shutterSpeedPath);
metadata.focalLength = ReadDouble(reader, EXIF_FOCAL_LENGTH); metadata.focalLength = ReadDouble(reader, focalLengthPath);
metadata.exposureBias = ReadDouble(reader, EXIF_EXPOSURE_BIAS); metadata.exposureBias = ReadDouble(reader, exposureBiasPath);
metadata.flash = ReadInteger(reader, EXIF_FLASH); metadata.flash = ReadInteger(reader, flashPath);
// Extract image properties // Extract image properties
metadata.width = ReadInteger(reader, EXIF_WIDTH); metadata.width = ReadInteger(reader, widthPath);
metadata.height = ReadInteger(reader, EXIF_HEIGHT); metadata.height = ReadInteger(reader, heightPath);
metadata.orientation = ReadInteger(reader, EXIF_ORIENTATION); metadata.orientation = ReadInteger(reader, orientationPath);
metadata.colorSpace = ReadInteger(reader, EXIF_COLOR_SPACE); metadata.colorSpace = ReadInteger(reader, colorSpacePath);
// Extract author information // Extract author information
metadata.author = ReadString(reader, EXIF_ARTIST); metadata.author = ReadString(reader, artistPath);
metadata.copyright = ReadString(reader, EXIF_COPYRIGHT); metadata.copyright = ReadString(reader, copyrightPath);
} }
void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat)
{ {
if (!reader) if (!reader)
{ {
return; return;
} }
auto lat = ReadMetadata(reader, GPS_LATITUDE); // Select the correct paths based on container format
auto lon = ReadMetadata(reader, GPS_LONGITUDE); const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD);
auto latRef = ReadMetadata(reader, GPS_LATITUDE_REF);
auto lonRef = ReadMetadata(reader, GPS_LONGITUDE_REF); const auto& latitudePath = useIfdPaths ? HEIF_GPS_LATITUDE : GPS_LATITUDE;
const auto& longitudePath = useIfdPaths ? HEIF_GPS_LONGITUDE : GPS_LONGITUDE;
const auto& latitudeRefPath = useIfdPaths ? HEIF_GPS_LATITUDE_REF : GPS_LATITUDE_REF;
const auto& longitudeRefPath = useIfdPaths ? HEIF_GPS_LONGITUDE_REF : GPS_LONGITUDE_REF;
const auto& altitudePath = useIfdPaths ? HEIF_GPS_ALTITUDE : GPS_ALTITUDE;
auto lat = ReadMetadata(reader, latitudePath);
auto lon = ReadMetadata(reader, longitudePath);
auto latRef = ReadMetadata(reader, latitudeRefPath);
auto lonRef = ReadMetadata(reader, longitudeRefPath);
if (lat && lon) if (lat && lon)
{ {
@@ -584,7 +678,7 @@ void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFM
metadata.longitude = coords.second; metadata.longitude = coords.second;
} }
auto alt = ReadMetadata(reader, GPS_ALTITUDE); auto alt = ReadMetadata(reader, altitudePath);
if (alt) if (alt)
{ {
metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get()); metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get());

View File

@@ -9,14 +9,32 @@
#include <wincodec.h> #include <wincodec.h>
#include <atlbase.h> #include <atlbase.h>
// Forward declarations for unit test friend classes
namespace WICMetadataExtractorTests
{
class ExtractAVIFMetadataTests;
}
namespace PowerRenameLib namespace PowerRenameLib
{ {
/// <summary>
/// Metadata path format based on container type
/// </summary>
enum class MetadataPathFormat
{
JPEG, // Uses /app1/ifd/... paths (JPEG)
IFD // Uses /ifd/... paths (HEIF, TIFF, etc.)
};
/// <summary> /// <summary>
/// Windows Imaging Component (WIC) implementation for metadata extraction /// Windows Imaging Component (WIC) implementation for metadata extraction
/// Provides efficient batch extraction of all metadata types with built-in caching /// Provides efficient batch extraction of all metadata types with built-in caching
/// </summary> /// </summary>
class WICMetadataExtractor class WICMetadataExtractor
{ {
// Friend declarations for unit testing
friend class WICMetadataExtractorTests::ExtractAVIFMetadataTests;
public: public:
WICMetadataExtractor(); WICMetadataExtractor();
~WICMetadataExtractor(); ~WICMetadataExtractor();
@@ -45,10 +63,13 @@ namespace PowerRenameLib
bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata); bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata);
// Batch extraction methods // Batch extraction methods
void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat);
void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat);
void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata); void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata);
// Internal container format detection
MetadataPathFormat GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder);
// Field reading helpers // Field reading helpers
std::optional<SYSTEMTIME> ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path); std::optional<SYSTEMTIME> ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path);
std::optional<std::wstring> ReadString(IWICMetadataQueryReader* reader, const std::wstring& path); std::optional<std::wstring> ReadString(IWICMetadataQueryReader* reader, const std::wstring& path);

View File

@@ -89,6 +89,12 @@
<None Include="testdata\xmp_test_2.jpg"> <None Include="testdata\xmp_test_2.jpg">
<DeploymentContent>true</DeploymentContent> <DeploymentContent>true</DeploymentContent>
</None> </None>
<None Include="testdata\heif_test.heic">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\avif_test.avif">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\ATTRIBUTION.md"> <None Include="testdata\ATTRIBUTION.md">
<DeploymentContent>true</DeploymentContent> <DeploymentContent>true</DeploymentContent>
</None> </None>

View File

@@ -241,4 +241,336 @@ namespace WICMetadataExtractorTests
Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str()); Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str());
} }
}; };
TEST_CLASS(ExtractHEIFMetadataTests)
{
public:
TEST_METHOD(ExtractHEIF_EXIF_CameraInfo)
{
// Test HEIF EXIF extraction - camera information
// This test requires HEIF Image Extensions to be installed from Microsoft Store
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
// Check if file exists first
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
// If HEIF extension is not installed, extraction may fail
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify camera information from iPhone
Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present");
Assert::AreEqual(L"Apple", metadata.cameraMake.value().c_str(), L"Camera make should be Apple");
Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present");
// Model should contain "iPhone"
Assert::IsTrue(metadata.cameraModel.value().find(L"iPhone") != std::wstring::npos,
L"Camera model should contain iPhone");
}
TEST_METHOD(ExtractHEIF_EXIF_DateTaken)
{
// Test HEIF EXIF extraction - date taken
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify date taken is present
Assert::IsTrue(metadata.dateTaken.has_value(), L"Date taken should be present");
// Verify the date is a reasonable year (2020-2030 range)
SYSTEMTIME dt = metadata.dateTaken.value();
Assert::IsTrue(dt.wYear >= 2020 && dt.wYear <= 2030, L"Date taken year should be reasonable");
}
TEST_METHOD(ExtractHEIF_EXIF_ShootingParameters)
{
// Test HEIF EXIF extraction - shooting parameters
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify shooting parameters are present
Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present");
Assert::IsTrue(metadata.iso.value() > 0, L"ISO should be positive");
Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present");
Assert::IsTrue(metadata.aperture.value() > 0, L"Aperture should be positive");
Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present");
Assert::IsTrue(metadata.shutterSpeed.value() > 0, L"Shutter speed should be positive");
Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present");
Assert::IsTrue(metadata.focalLength.value() > 0, L"Focal length should be positive");
}
TEST_METHOD(ExtractHEIF_EXIF_GPS)
{
// Test HEIF EXIF extraction - GPS coordinates
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify GPS coordinates are present (if the test file has GPS data)
if (metadata.latitude.has_value() && metadata.longitude.has_value())
{
// Latitude should be between -90 and 90
Assert::IsTrue(metadata.latitude.value() >= -90.0 && metadata.latitude.value() <= 90.0,
L"Latitude should be valid");
// Longitude should be between -180 and 180
Assert::IsTrue(metadata.longitude.value() >= -180.0 && metadata.longitude.value() <= 180.0,
L"Longitude should be valid");
}
else
{
Logger::WriteMessage(L"GPS data not present in test file");
}
}
TEST_METHOD(ExtractHEIF_EXIF_ImageDimensions)
{
// Test HEIF EXIF extraction - image dimensions
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify image dimensions are present
Assert::IsTrue(metadata.width.has_value(), L"Width should be present");
Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive");
Assert::IsTrue(metadata.height.has_value(), L"Height should be present");
Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive");
}
TEST_METHOD(ExtractHEIF_EXIF_LensModel)
{
// Test HEIF EXIF extraction - lens model
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"HEIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
return;
}
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
// Verify lens model is present (iPhone photos typically have this)
if (metadata.lensModel.has_value())
{
Assert::IsFalse(metadata.lensModel.value().empty(), L"Lens model should not be empty");
}
else
{
Logger::WriteMessage(L"Lens model not present in test file");
}
}
};
TEST_CLASS(ExtractAVIFMetadataTests)
{
public:
TEST_METHOD(ExtractAVIF_EXIF_CameraInfo)
{
// Test AVIF EXIF extraction - camera information
// This test requires AV1 Video Extension to be installed from Microsoft Store
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"AVIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
return;
}
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
// Verify camera information
if (metadata.cameraMake.has_value())
{
Assert::IsFalse(metadata.cameraMake.value().empty(), L"Camera make should not be empty");
}
if (metadata.cameraModel.has_value())
{
Assert::IsFalse(metadata.cameraModel.value().empty(), L"Camera model should not be empty");
}
}
TEST_METHOD(ExtractAVIF_EXIF_DateTaken)
{
// Test AVIF EXIF extraction - date taken
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"AVIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
return;
}
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
// Verify date taken is present
if (metadata.dateTaken.has_value())
{
SYSTEMTIME dt = metadata.dateTaken.value();
Assert::IsTrue(dt.wYear >= 2000 && dt.wYear <= 2100, L"Date taken year should be reasonable");
}
else
{
Logger::WriteMessage(L"Date taken not present in AVIF test file");
}
}
TEST_METHOD(ExtractAVIF_EXIF_ImageDimensions)
{
// Test AVIF EXIF extraction - image dimensions
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
if (!std::filesystem::exists(testFile))
{
Logger::WriteMessage(L"AVIF test file not found, skipping test");
return;
}
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
if (!result)
{
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
return;
}
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
// Verify image dimensions are present
if (metadata.width.has_value())
{
Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive");
}
if (metadata.height.has_value())
{
Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive");
}
}
};
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

View File

@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using ManagedCommon;
using Windows.Management.Deployment;
using Windows.System;
namespace Microsoft.PowerToys.Settings.UI.Helpers
{
/// <summary>
/// Helper class to manage installation status and installation command for a Microsoft Store extension.
/// </summary>
public class StoreExtensionHelper : INotifyPropertyChanged
{
private readonly string _packageFamilyName;
private readonly string _storeUri;
private readonly string _extensionName;
private bool? _isInstalled;
public event PropertyChangedEventHandler PropertyChanged;
public StoreExtensionHelper(string packageFamilyName, string storeUri, string extensionName)
{
_packageFamilyName = packageFamilyName ?? throw new ArgumentNullException(nameof(packageFamilyName));
_storeUri = storeUri ?? throw new ArgumentNullException(nameof(storeUri));
_extensionName = extensionName ?? throw new ArgumentNullException(nameof(extensionName));
InstallCommand = new AsyncCommand(InstallExtensionAsync);
}
/// <summary>
/// Gets a value indicating whether the extension is installed.
/// </summary>
public bool IsInstalled
{
get
{
if (!_isInstalled.HasValue)
{
_isInstalled = CheckExtensionInstalled();
}
return _isInstalled.Value;
}
}
/// <summary>
/// Gets the command to install the extension.
/// </summary>
public ICommand InstallCommand { get; }
/// <summary>
/// Refreshes the installation status of the extension.
/// </summary>
public void RefreshStatus()
{
_isInstalled = null;
OnPropertyChanged(nameof(IsInstalled));
}
private bool CheckExtensionInstalled()
{
try
{
var packageManager = new PackageManager();
var packages = packageManager.FindPackagesForUser(string.Empty, _packageFamilyName);
return packages.Any();
}
catch (Exception ex)
{
Logger.LogError($"Failed to check extension installation status: {_packageFamilyName}", ex);
return false;
}
}
private async Task InstallExtensionAsync()
{
try
{
await Launcher.LaunchUriAsync(new Uri(_storeUri));
}
catch (Exception ex)
{
Logger.LogError($"Failed to open {_extensionName} extension store page", ex);
}
}
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@@ -7,10 +7,18 @@
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
xmlns:ui="using:CommunityToolkit.WinUI" xmlns:ui="using:CommunityToolkit.WinUI"
AutomationProperties.LandmarkType="Main" AutomationProperties.LandmarkType="Main"
mc:Ignorable="d"> mc:Ignorable="d">
<local:NavigablePage.Resources>
<tkconverters:BoolToVisibilityConverter
x:Key="BoolToInvertedVisibilityConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
</local:NavigablePage.Resources>
<controls:SettingsPageControl x:Uid="PowerRename" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PowerRename.png"> <controls:SettingsPageControl x:Uid="PowerRename" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PowerRename.png">
<controls:SettingsPageControl.ModuleContent> <controls:SettingsPageControl.ModuleContent>
<StackPanel <StackPanel
@@ -97,6 +105,48 @@
IsOn="{x:Bind ViewModel.UseBoostLib, Mode=TwoWay}" /> IsOn="{x:Bind ViewModel.UseBoostLib, Mode=TwoWay}" />
</tkcontrols:SettingsCard> </tkcontrols:SettingsCard>
</controls:SettingsGroup> </controls:SettingsGroup>
<controls:SettingsGroup x:Uid="PowerRename_ExtensionsHeader" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard
Name="PowerRenameHeifExtension"
x:Uid="PowerRename_HeifExtension"
HeaderIcon="{ui:FontIcon Glyph=&#xEB9F;}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
VerticalAlignment="Center"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Glyph="&#xEC61;"
Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay}" />
<TextBlock
x:Uid="PowerRename_HeifExtension_Installed"
VerticalAlignment="Center"
Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay}" />
<Button
x:Uid="PowerRename_HeifExtension_Install"
Command="{x:Bind ViewModel.InstallHeifExtensionCommand}"
Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" />
</StackPanel>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PowerRenameAvifExtension"
x:Uid="PowerRename_AvifExtension"
HeaderIcon="{ui:FontIcon Glyph=&#xEB9F;}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
VerticalAlignment="Center"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Glyph="&#xEC61;"
Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay}" />
<TextBlock
x:Uid="PowerRename_AvifExtension_Installed"
VerticalAlignment="Center"
Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay}" />
<Button
x:Uid="PowerRename_AvifExtension_Install"
Command="{x:Bind ViewModel.InstallAvifExtensionCommand}"
Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" />
</StackPanel>
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
</StackPanel> </StackPanel>
</controls:SettingsPageControl.ModuleContent> </controls:SettingsPageControl.ModuleContent>

View File

@@ -1698,6 +1698,35 @@ Made with 💗 by Microsoft and the PowerToys community.</value>
<value>Provides extended features but may use different regex syntax</value> <value>Provides extended features but may use different regex syntax</value>
<comment>Boost is a product name, should not be translated</comment> <comment>Boost is a product name, should not be translated</comment>
</data> </data>
<data name="PowerRename_ExtensionsHeader.Header" xml:space="preserve">
<value>Extensions</value>
</data>
<data name="PowerRename_HeifExtension.Header" xml:space="preserve">
<value>HEIF image metadata extraction</value>
</data>
<data name="PowerRename_HeifExtension.Description" xml:space="preserve">
<value>Requires HEIF Image Extensions from Microsoft Store to extract metadata from HEIC/HEIF files</value>
<comment>HEIF is a file format name, do not translate</comment>
</data>
<data name="PowerRename_HeifExtension_Install.Content" xml:space="preserve">
<value>Install from Microsoft Store</value>
</data>
<data name="PowerRename_HeifExtension_Installed.Text" xml:space="preserve">
<value>Installed</value>
</data>
<data name="PowerRename_AvifExtension.Header" xml:space="preserve">
<value>AVIF image metadata extraction</value>
</data>
<data name="PowerRename_AvifExtension.Description" xml:space="preserve">
<value>Requires AV1 Video Extension from Microsoft Store to extract metadata from AVIF files</value>
<comment>AVIF is a file format name, do not translate</comment>
</data>
<data name="PowerRename_AvifExtension_Install.Content" xml:space="preserve">
<value>Install from Microsoft Store</value>
</data>
<data name="PowerRename_AvifExtension_Installed.Text" xml:space="preserve">
<value>Installed</value>
</data>
<data name="MadeWithOssLove.Text" xml:space="preserve"> <data name="MadeWithOssLove.Text" xml:space="preserve">
<value>Made with 💗 by Microsoft and the PowerToys community.</value> <value>Made with 💗 by Microsoft and the PowerToys community.</value>
</data> </data>

View File

@@ -5,9 +5,12 @@
using System; using System;
using System.IO; using System.IO;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows.Input;
using global::PowerToys.GPOWrapper; using global::PowerToys.GPOWrapper;
using ManagedCommon; using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
@@ -67,6 +70,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_autoComplete = Settings.Properties.MRUEnabled.Value; _autoComplete = Settings.Properties.MRUEnabled.Value;
_powerRenameUseBoostLib = Settings.Properties.UseBoostLib.Value; _powerRenameUseBoostLib = Settings.Properties.UseBoostLib.Value;
// Initialize extension helpers
HeifExtension = new StoreExtensionHelper(
"Microsoft.HEIFImageExtension_8wekyb3d8bbwe",
"ms-windows-store://pdp/?ProductId=9PMMSR1CGPWG",
"HEIF");
AvifExtension = new StoreExtensionHelper(
"Microsoft.AV1VideoExtension_8wekyb3d8bbwe",
"ms-windows-store://pdp/?ProductId=9MVZQVXJBQ9V",
"AV1");
InitializeEnabledValue(); InitializeEnabledValue();
} }
@@ -270,5 +284,31 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(GlobalAndMruEnabled)); OnPropertyChanged(nameof(GlobalAndMruEnabled));
} }
// Store extension helpers
public StoreExtensionHelper HeifExtension { get; private set; }
public StoreExtensionHelper AvifExtension { get; private set; }
// Convenience properties for XAML binding
public bool IsHeifExtensionInstalled => HeifExtension.IsInstalled;
public bool IsAvifExtensionInstalled => AvifExtension.IsInstalled;
public ICommand InstallHeifExtensionCommand => HeifExtension.InstallCommand;
public ICommand InstallAvifExtensionCommand => AvifExtension.InstallCommand;
public void RefreshHeifExtensionStatus()
{
HeifExtension.RefreshStatus();
OnPropertyChanged(nameof(IsHeifExtensionInstalled));
}
public void RefreshAvifExtensionStatus()
{
AvifExtension.RefreshStatus();
OnPropertyChanged(nameof(IsAvifExtensionInstalled));
}
} }
} }