diff --git a/Resources/doc/usage.md b/Resources/doc/usage.md
index 2242ba1..05f6576 100644
--- a/Resources/doc/usage.md
+++ b/Resources/doc/usage.md
@@ -36,3 +36,28 @@ $exif = $reader->read('/path/to/file');
echo 'Title: ' . $exif->getTitle() . PHP_EOL;
```
+
+### Google Motion Photos
+
+A Google Motion Photo is a JPEG with a short video appended to it. The reader
+reports the length of that video, in bytes counted from the end of the file,
+as `MicroVideoOffset`, whatever the adapter:
+
+```php
+read('/path/to/PXL_20261015_222940892.MP.jpg');
+
+$videoLength = $exif->getMicroVideoOffset(); // false if the file has no video
+```
+
+Both formats are supported: the legacy `GCamera:MicroVideoOffset` (Android 10
+and earlier) and Motion Photo 1.0 (`GCamera:MotionPhoto` with a
+`Container:Directory`, Android 11 and later, Ultra HDR files included). The
+XMP is read from the JPEG itself by `\PHPExif\MotionPhoto\MotionPhotoLocator`,
+which can also be used on its own:
+
+```php
+= self::MIN_BOX_SIZE &&
+ $boxSize <= self::MAX_BOX_SIZE;
+ }
+}
diff --git a/lib/PHPExif/MotionPhoto/JpegHeader.php b/lib/PHPExif/MotionPhoto/JpegHeader.php
new file mode 100644
index 0000000..a363c78
--- /dev/null
+++ b/lib/PHPExif/MotionPhoto/JpegHeader.php
@@ -0,0 +1,19 @@
+= 0 && self::position($stream) + $payloadLength <= $fileSize;
+
+ return $isValid ? $payloadLength : null;
+ }
+
+ /**
+ * Current offset in the stream.
+ *
+ * Not `Safe\ftell()`: Safe 2.x, still allowed by composer.json, has no
+ * wrapper for it.
+ *
+ * @param resource $stream
+ *
+ * @phpstan-impure
+ */
+ private static function position($stream): int
+ {
+ // @phpstan-ignore theCodingMachineSafe.function (no wrapper in Safe 2.x, false is handled here)
+ $position = \ftell($stream);
+ if ($position === false) {
+ throw new \RuntimeException('Cannot read the stream position');
+ }
+
+ return $position;
+ }
+
+ /**
+ * @return string|null the XMP packet if `$payload` is a standard XMP segment payload
+ */
+ private static function xmpOf(string $payload): ?string
+ {
+ return str_starts_with($payload, self::XMP_NAMESPACE) ? substr($payload, strlen(self::XMP_NAMESPACE)) : null;
+ }
+
+ /**
+ * @param resource $stream
+ *
+ * @phpstan-impure
+ */
+ private static function readByte($stream): ?int
+ {
+ $byte = self::readBytes($stream, 1);
+
+ return $byte === '' ? null : ord($byte);
+ }
+
+ /**
+ * @param resource $stream
+ *
+ * @return string fewer than `$length` bytes at the end of the stream
+ *
+ * @phpstan-impure
+ */
+ private static function readBytes($stream, int $length): string
+ {
+ return $length < 1 ? '' : fread($stream, $length);
+ }
+
+ /**
+ * @param resource $stream
+ *
+ * @return string always empty, so callers can treat a skipped payload like a read one
+ */
+ private static function skip($stream, int $length): string
+ {
+ fseek($stream, $length, SEEK_CUR);
+
+ return '';
+ }
+}
diff --git a/lib/PHPExif/MotionPhoto/MotionPhotoLocator.php b/lib/PHPExif/MotionPhoto/MotionPhotoLocator.php
new file mode 100644
index 0000000..1b6b87f
--- /dev/null
+++ b/lib/PHPExif/MotionPhoto/MotionPhotoLocator.php
@@ -0,0 +1,89 @@
+xmp ?? '');
+ $declared = self::firstValidLength($stream, $fileSize, $header, [$xmp->itemLength, $xmp->legacyOffset]);
+ if ($declared !== 0 || !$xmp->isMotionPhoto) {
+ return $declared;
+ }
+
+ // Scan only after the primary image, so compressed image data that
+ // happens to look like an MP4 header cannot be taken for the video.
+ $imageEnd = JpegImageEnd::find($stream, $header->imageDataOffset, $fileSize);
+ $videoStart = $imageEnd === null ? null : FtypBox::scan($stream, $imageEnd, $fileSize);
+
+ return $videoStart === null ? 0 : $fileSize - $videoStart;
+ }
+
+ /**
+ * @param resource $stream
+ * @param int[] $lengths candidate video lengths, `0` meaning "not declared"
+ */
+ private static function firstValidLength($stream, int $fileSize, JpegHeader $header, array $lengths): int
+ {
+ foreach ($lengths as $length) {
+ $isAfterHeader = $length > 0 && $length <= $fileSize - $header->imageDataOffset;
+ if ($isAfterHeader && FtypBox::isAt($stream, $fileSize - $length)) {
+ return $length;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/lib/PHPExif/MotionPhoto/MotionPhotoXmp.php b/lib/PHPExif/MotionPhoto/MotionPhotoXmp.php
new file mode 100644
index 0000000..226872b
--- /dev/null
+++ b/lib/PHPExif/MotionPhoto/MotionPhotoXmp.php
@@ -0,0 +1,56 @@
+start(...), $handler->end(...));
+ xml_set_character_data_handler($parser, $handler->text(...));
+
+ $isWellFormed = xml_parse($parser, $xml, true) === 1;
+
+ return $isWellFormed ? $handler->result() : self::none();
+ }
+}
diff --git a/lib/PHPExif/MotionPhoto/MotionPhotoXmpHandler.php b/lib/PHPExif/MotionPhoto/MotionPhotoXmpHandler.php
new file mode 100644
index 0000000..e24c5eb
--- /dev/null
+++ b/lib/PHPExif/MotionPhoto/MotionPhotoXmpHandler.php
@@ -0,0 +1,104 @@
+
+ * 1
+ *
+ * Names arrive as `|`, so prefixes do not matter.
+ *
+ * @internal
+ */
+final class MotionPhotoXmpHandler
+{
+ public const SEPARATOR = '|';
+
+ private const MOTION_PHOTO = MotionPhotoXmp::NS_CAMERA . self::SEPARATOR . 'MotionPhoto';
+ private const MICRO_VIDEO_OFFSET = MotionPhotoXmp::NS_CAMERA . self::SEPARATOR . 'MicroVideoOffset';
+ private const CONTAINER_ITEM = MotionPhotoXmp::NS_CONTAINER . self::SEPARATOR . 'Item';
+ private const ITEM_SEMANTIC = MotionPhotoXmp::NS_ITEM . self::SEPARATOR . 'Semantic';
+ private const ITEM_LENGTH = MotionPhotoXmp::NS_ITEM . self::SEPARATOR . 'Length';
+ private const SEMANTIC_MOTION_PHOTO = 'MotionPhoto';
+ private const CAMERA_PROPERTIES = [self::MOTION_PHOTO => true, self::MICRO_VIDEO_OFFSET => true];
+ private const ITEM_PROPERTIES = [self::ITEM_SEMANTIC => true, self::ITEM_LENGTH => true];
+
+ /** @var array raw values by qualified name: the two camera properties */
+ private array $camera = [];
+
+ /** @var array|null raw `Item:*` values of the container item being read */
+ private ?array $item = null;
+
+ private ?string $videoItemLength = null;
+
+ /** qualified name of the element whose text is being collected */
+ private ?string $textOf = null;
+ private string $text = '';
+
+ /**
+ * @param array $attributes
+ */
+ public function start(\XMLParser $parser, string $name, array $attributes): void
+ {
+ $this->camera += array_intersect_key($attributes, self::CAMERA_PROPERTIES);
+ $this->item = $name === self::CONTAINER_ITEM
+ ? array_intersect_key($attributes, self::ITEM_PROPERTIES)
+ : $this->item;
+ $this->textOf = $name;
+ $this->text = '';
+ }
+
+ public function text(\XMLParser $parser, string $data): void
+ {
+ $this->text .= $data;
+ }
+
+ public function end(\XMLParser $parser, string $name): void
+ {
+ $value = trim($this->text);
+ $isTextElement = $this->textOf === $name;
+ $this->textOf = null;
+ $this->text = '';
+
+ if ($isTextElement && in_array($name, [self::MOTION_PHOTO, self::MICRO_VIDEO_OFFSET], true)) {
+ $this->camera[$name] ??= $value;
+ }
+ if ($isTextElement && $this->item !== null && in_array($name, [self::ITEM_SEMANTIC, self::ITEM_LENGTH], true)) {
+ $this->item[$name] ??= $value;
+ }
+ if ($name === self::CONTAINER_ITEM) {
+ $this->closeItem();
+ }
+ }
+
+ public function result(): MotionPhotoXmp
+ {
+ return new MotionPhotoXmp(
+ isMotionPhoto: trim($this->camera[self::MOTION_PHOTO] ?? '') === '1',
+ itemLength: self::toLength($this->videoItemLength),
+ legacyOffset: self::toLength($this->camera[self::MICRO_VIDEO_OFFSET] ?? null),
+ );
+ }
+
+ private function closeItem(): void
+ {
+ $isVideo = ($this->item[self::ITEM_SEMANTIC] ?? null) === self::SEMANTIC_MOTION_PHOTO;
+ $this->videoItemLength ??= $isVideo ? ($this->item[self::ITEM_LENGTH] ?? null) : null;
+ $this->item = null;
+ }
+
+ /**
+ * @return int the value if it is a plain non-negative decimal integer, `0` otherwise
+ */
+ private static function toLength(?string $raw): int
+ {
+ $value = trim($raw ?? '');
+
+ return ctype_digit($value) ? (int) $value : 0;
+ }
+}
diff --git a/lib/PHPExif/Reader/Reader.php b/lib/PHPExif/Reader/Reader.php
index 27d8a82..93ce94e 100644
--- a/lib/PHPExif/Reader/Reader.php
+++ b/lib/PHPExif/Reader/Reader.php
@@ -10,6 +10,7 @@
use PHPExif\Contracts\ReaderInterface;
use PHPExif\Exif;
use PHPExif\Enum\ReaderType;
+use PHPExif\MotionPhoto\MotionPhotoLocator;
/**
* PHP Exif Reader
@@ -52,12 +53,23 @@ public static function factory(ReaderType $type, string $path = ''): Reader
/**
* Reads & parses the EXIF data from given file
*
+ * When the adapter reports no `MicroVideoOffset`, the video of a Google
+ * Motion Photo (legacy or Motion Photo 1.0 format) is located from the
+ * file itself, so every adapter reports it the same way.
+ *
* @param string $file
* @return Exif Instance of Exif object with data
* @throws PhpExifReaderException If the EXIF data could not be read
*/
public function read(string $file): Exif
{
- return $this->adapter->getExifFromFile($file);
+ $exif = $this->adapter->getExifFromFile($file);
+ if ($exif->getMicroVideoOffset() !== false) {
+ return $exif;
+ }
+
+ $videoLength = MotionPhotoLocator::locate($file);
+
+ return $videoLength > 0 ? $exif->setMicroVideoOffset((string) $videoLength) : $exif;
}
}
diff --git a/tests/PHPExif/MotionPhoto/JpegXmpReaderTest.php b/tests/PHPExif/MotionPhoto/JpegXmpReaderTest.php
new file mode 100644
index 0000000..a615658
--- /dev/null
+++ b/tests/PHPExif/MotionPhoto/JpegXmpReaderTest.php
@@ -0,0 +1,147 @@
+';
+
+ #[Group('motionphoto')]
+ public function testReturnsXmpAndImageDataOffset(): void
+ {
+ $head = self::SOI .
+ self::segment(0xE0, "JFIF\x00\x01\x01") .
+ self::segment(0xE1, "Exif\x00\x00II*\x00") .
+ self::segment(0xE1, self::XMP_NAMESPACE . self::XMP) .
+ self::segment(0xDB, str_repeat("\x01", 65));
+ $header = $this->read($head . self::scan());
+
+ self::assertInstanceOf(JpegHeader::class, $header);
+ self::assertSame(self::XMP, $header->xmp);
+ self::assertSame(strlen($head), $header->imageDataOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testXmpIsNullWithoutXmpSegment(): void
+ {
+ $head = self::SOI . self::segment(0xE1, "Exif\x00\x00II*\x00");
+ $header = $this->read($head . self::scan());
+
+ self::assertInstanceOf(JpegHeader::class, $header);
+ self::assertNull($header->xmp);
+ self::assertSame(strlen($head), $header->imageDataOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testExtendedXmpIsNotTheStandardPacket(): void
+ {
+ $header = $this->read(self::SOI . self::segment(0xE1, self::EXTENDED_XMP_NAMESPACE . self::XMP) . self::scan());
+
+ self::assertNull($header?->xmp);
+ }
+
+ #[Group('motionphoto')]
+ public function testFirstStandardPacketWins(): void
+ {
+ $header = $this->read(self::SOI .
+ self::segment(0xE1, self::XMP_NAMESPACE . self::XMP) .
+ self::segment(0xE1, self::XMP_NAMESPACE . '') .
+ self::scan());
+
+ self::assertSame(self::XMP, $header?->xmp);
+ }
+
+ #[Group('motionphoto')]
+ public function testFillBytesBeforeAMarkerAreSkipped(): void
+ {
+ $header = $this->read(self::SOI . "\xFF\xFF" . self::segment(0xE1, self::XMP_NAMESPACE . self::XMP) . self::scan());
+
+ self::assertSame(self::XMP, $header?->xmp);
+ }
+
+ #[Group('motionphoto')]
+ public function testSegmentsAfterTheImageDataAreNotRead(): void
+ {
+ $header = $this->read(self::SOI . self::scan() . self::segment(0xE1, self::XMP_NAMESPACE . self::XMP));
+
+ self::assertNull($header?->xmp);
+ self::assertSame(2, $header?->imageDataOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testNotAJpeg(): void
+ {
+ self::assertNull($this->read("\x89PNG\r\n\x1A\n" . str_repeat("\x00", 32)));
+ }
+
+ #[Group('motionphoto')]
+ public function testEmptyStream(): void
+ {
+ self::assertNull($this->read(''));
+ }
+
+ #[Group('motionphoto')]
+ public function testTruncatedMarker(): void
+ {
+ self::assertNull($this->read(self::SOI . "\xFF\xE1\x00"));
+ }
+
+ #[Group('motionphoto')]
+ public function testMissingImageData(): void
+ {
+ self::assertNull($this->read(self::SOI . self::segment(0xE1, self::XMP_NAMESPACE . self::XMP)));
+ }
+
+ #[Group('motionphoto')]
+ public function testSegmentLengthBeyondTheFile(): void
+ {
+ self::assertNull($this->read(self::SOI . "\xFF\xE1\x40\x00" . self::XMP_NAMESPACE . self::XMP));
+ }
+
+ #[Group('motionphoto')]
+ public function testSegmentLengthBelowTwo(): void
+ {
+ self::assertNull($this->read(self::SOI . "\xFF\xE1\x00\x01" . self::scan()));
+ }
+
+ #[Group('motionphoto')]
+ public function testMarkerWithoutFfPrefix(): void
+ {
+ self::assertNull($this->read(self::SOI . "\x00\xE1\x00\x04ab" . self::scan()));
+ }
+
+ private function read(string $bytes): ?JpegHeader
+ {
+ $stream = fopen('php://memory', 'r+b');
+ fwrite($stream, $bytes);
+ rewind($stream);
+ try {
+ return JpegXmpReader::read($stream, strlen($bytes));
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ private static function segment(int $marker, string $payload): string
+ {
+ return "\xFF" . chr($marker) . pack('n', strlen($payload) + 2) . $payload;
+ }
+
+ /**
+ * A start-of-scan segment followed by some entropy-coded bytes and EOI.
+ */
+ private static function scan(): string
+ {
+ return self::segment(0xDA, "\x01\x01\x00\x00\x3F\x00") . "\x12\x34\x56\x78\xFF\xD9";
+ }
+}
diff --git a/tests/PHPExif/MotionPhoto/MotionPhotoLocatorTest.php b/tests/PHPExif/MotionPhoto/MotionPhotoLocatorTest.php
new file mode 100644
index 0000000..c099444
--- /dev/null
+++ b/tests/PHPExif/MotionPhoto/MotionPhotoLocatorTest.php
@@ -0,0 +1,239 @@
+ @unlink($path), $this->files);
+ parent::tearDown();
+ }
+
+ #[Group('motionphoto')]
+ public function testDeclaredItemLength(): void
+ {
+ $video = self::mp4(5000);
+ $file = $this->file(self::jpeg(self::motionPhoto(strlen($video))) . $video);
+
+ self::assertSame(5000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testUltraHdrLayoutSkipsTheGainMap(): void
+ {
+ $video = self::mp4(5000);
+ $gainMap = self::jpeg('');
+ $file = $this->file(self::jpeg(self::motionPhoto(strlen($video))) . $gainMap . $video);
+
+ self::assertSame(5000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testDeclaredLegacyOffset(): void
+ {
+ $video = self::mp4(3000);
+ $file = $this->file(self::jpeg('GCamera:MicroVideo="1" GCamera:MicroVideoOffset="3000"') . $video);
+
+ self::assertSame(3000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testWrongDeclaredLengthFallsBackToTheScan(): void
+ {
+ $video = self::mp4(5000);
+ $file = $this->file(self::jpeg(self::motionPhoto(5007)) . $video);
+
+ self::assertSame(5000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testWrongLegacyOffsetIsNotScanned(): void
+ {
+ $video = self::mp4(3000);
+ $file = $this->file(self::jpeg('GCamera:MicroVideo="1" GCamera:MicroVideoOffset="3333"') . $video);
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testMissingVideoItemIsScanned(): void
+ {
+ $video = self::mp4(4000);
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"') . $video);
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanMiss(): void
+ {
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"') . str_repeat("\x00", 4000));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanRejectsUnknownBrands(): void
+ {
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"') . self::mp4(4000, 'heic'));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanRejectsImplausibleBoxSizes(): void
+ {
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"') . self::mp4(4000, 'isom', 4096));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanFindsABoxAcrossAChunkBoundary(): void
+ {
+ $video = self::mp4(4000);
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"') . str_repeat("\x00", self::CHUNK_SIZE - 6) . $video);
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file));
+ }
+
+ /**
+ *: compressed image data may contain bytes that look like
+ * an MP4 header; the scan starts after the primary image's EOI marker.
+ */
+ #[Group('motionphoto')]
+ public function testScanSkipsTheImageDataOfThePrimaryImage(): void
+ {
+ $lookAlike = self::mp4(24);
+ $video = self::mp4(4000);
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"', 200, $lookAlike . "\x12\x12") . $video);
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanFindsAnEoiAcrossAChunkBoundary(): void
+ {
+ $video = self::mp4(4000);
+ // SOS at offset 0 of the image data: FF at 65535, D9 at 65536.
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"', self::CHUNK_SIZE + 1) . $video);
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanNeedsTheEndOfThePrimaryImage(): void
+ {
+ $truncated = substr(self::jpeg('GCamera:MotionPhoto="1"'), 0, -2);
+ $file = $this->file($truncated . self::mp4(4000));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testPlainJpegWithTrailingVideoIsNotAMotionPhoto(): void
+ {
+ $file = $this->file(self::jpeg('') . self::mp4(4000));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testLengthOfTheWholeFileIsRejected(): void
+ {
+ $video = self::mp4(4000);
+ $bytes = self::jpeg(self::motionPhoto(999999)) . $video;
+ $file = $this->file($bytes);
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file), 'falls back to the scan');
+ }
+
+ #[Group('motionphoto')]
+ public function testNotAJpeg(): void
+ {
+ $file = $this->file(self::mp4(4000));
+
+ self::assertSame(0, MotionPhotoLocator::locate($file));
+ }
+
+ #[Group('motionphoto')]
+ public function testUnreadableFileReturnsZero(): void
+ {
+ self::assertSame(0, MotionPhotoLocator::locate('/nonexistent/' . uniqid() . '.jpg'));
+ }
+
+ #[Group('motionphoto')]
+ public function testSyntheticSamples(): void
+ {
+ self::assertSame(10705, MotionPhotoLocator::locate(PHPEXIF_TEST_ROOT . '/files/motion_photo_v1.jpg'));
+ self::assertSame(10705, MotionPhotoLocator::locate(PHPEXIF_TEST_ROOT . '/files/motion_photo_ultrahdr.jpg'));
+ }
+
+ #[Group('motionphoto')]
+ public function testScanUsesConstantMemory(): void
+ {
+ $video = self::mp4(4000);
+ $file = $this->file(self::jpeg('GCamera:MotionPhoto="1"', 20 * 1024 * 1024) . $video);
+ gc_collect_cycles();
+ memory_reset_peak_usage();
+ $before = memory_get_usage();
+
+ self::assertSame(4000, MotionPhotoLocator::locate($file));
+ self::assertLessThan(2 * 1024 * 1024, memory_get_peak_usage() - $before);
+ }
+
+ private function file(string $bytes): string
+ {
+ $path = tempnam(sys_get_temp_dir(), 'phpexif-mp-');
+ $this->files[] = $path;
+ file_put_contents($path, $bytes);
+
+ return $path;
+ }
+
+ private static function motionPhoto(int $videoLength): string
+ {
+ return 'GCamera:MotionPhoto="1">' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '';
+ $app1 = $xmp === '' ? '' : "\xFF\xE1" . pack('n', strlen($xmp) + 2) . $xmp;
+ $sos = "\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00";
+
+ return "\xFF\xD8" . $app1 . $sos . str_repeat("\x12", $imageBytes - 12 - strlen($imageData)) . $imageData . "\xFF\xD9";
+ }
+
+ private static function mp4(int $length, string $brand = 'isom', int $boxSize = 24): string
+ {
+ $ftyp = pack('N', $boxSize) . 'ftyp' . $brand . "\x00\x00\x02\x00" . 'isommp41';
+
+ return $ftyp . str_repeat("\x00", $length - strlen($ftyp));
+ }
+}
diff --git a/tests/PHPExif/MotionPhoto/MotionPhotoXmpTest.php b/tests/PHPExif/MotionPhoto/MotionPhotoXmpTest.php
new file mode 100644
index 0000000..6896aa7
--- /dev/null
+++ b/tests/PHPExif/MotionPhoto/MotionPhotoXmpTest.php
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ XML;
+
+ #[Group('motionphoto')]
+ public function testPixelUltraHdrMotionPhoto(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::PIXEL_XMP);
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(3725212, $xmp->itemLength);
+ self::assertSame(0, $xmp->legacyOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testMotionPhotoWithoutGainMap(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::motionPhoto(
+ self::item('Primary') . self::item('MotionPhoto', 'Item:Length="10705" Item:Padding="0"')
+ ));
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(10705, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testLengthBeforeSemantic(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::motionPhoto(''));
+
+ self::assertSame(42, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testMotionPhotoFlagWithoutVideoItem(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::motionPhoto(self::item('Primary')));
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(0, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testMotionPhotoFlagWithoutDirectory(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::description('GCamera:MotionPhoto="1"', ''));
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(0, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testVideoItemWithoutLength(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::motionPhoto(self::item('Primary') . self::item('MotionPhoto')));
+
+ self::assertSame(0, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testOnlyTheMotionPhotoItemLengthCounts(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::motionPhoto(self::item('GainMap', 'Item:Length="1753"')));
+
+ self::assertSame(0, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testLegacyMicroVideo(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::description('GCamera:MicroVideo="1" GCamera:MicroVideoVersion="1" GCamera:MicroVideoOffset="4495341" GCamera:MicroVideoPresentationTimestampUs="1366016"', ''));
+
+ self::assertFalse($xmp->isMotionPhoto);
+ self::assertSame(0, $xmp->itemLength);
+ self::assertSame(4495341, $xmp->legacyOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testElementSyntax(): void
+ {
+ $body = '1' .
+ '' .
+ 'MotionPhoto 777 ' .
+ '';
+ $xmp = MotionPhotoXmp::fromXml(self::description('', $body));
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(777, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testLegacyOffsetElementSyntax(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::description('', '1234'));
+
+ self::assertSame(1234, $xmp->legacyOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testNamespaceIsMatchedByUriNotPrefix(): void
+ {
+ $xml = '' .
+ '' .
+ '' .
+ '';
+ $xmp = MotionPhotoXmp::fromXml($xml);
+
+ self::assertTrue($xmp->isMotionPhoto);
+ self::assertSame(99, $xmp->itemLength);
+ }
+
+ #[Group('motionphoto')]
+ public function testWrongNamespaceIsIgnored(): void
+ {
+ $xml = '' .
+ '' .
+ '';
+
+ self::assertNone(MotionPhotoXmp::fromXml($xml));
+ }
+
+ #[Group('motionphoto')]
+ public function testMotionPhotoFlagMustBeOne(): void
+ {
+ self::assertFalse(MotionPhotoXmp::fromXml(self::description('GCamera:MotionPhoto="0"', ''))->isMotionPhoto);
+ }
+
+ #[Group('motionphoto')]
+ public function testInvalidNumbersReadAsZero(): void
+ {
+ $xmp = MotionPhotoXmp::fromXml(self::description(
+ 'GCamera:MotionPhoto="1" GCamera:MicroVideoOffset="-5"',
+ '' . self::item('MotionPhoto', 'Item:Length="12abc"') . ''
+ ));
+
+ self::assertSame(0, $xmp->itemLength);
+ self::assertSame(0, $xmp->legacyOffset);
+ }
+
+ #[Group('motionphoto')]
+ public function testUnrelatedXmp(): void
+ {
+ $xml = '' .
+ 'Mongolia' .
+ '';
+
+ self::assertNone(MotionPhotoXmp::fromXml($xml));
+ }
+
+ #[Group('motionphoto')]
+ public function testMalformedXml(): void
+ {
+ self::assertNone(MotionPhotoXmp::fromXml(substr(self::PIXEL_XMP, 0, 900)));
+ }
+
+ #[Group('motionphoto')]
+ public function testEmptyString(): void
+ {
+ self::assertNone(MotionPhotoXmp::fromXml(''));
+ }
+
+ #[Group('motionphoto')]
+ public function testExternalEntitiesAreNotResolved(): void
+ {
+ $xml = ']>' .
+ '' .
+ '&ext;' .
+ '';
+
+ self::assertSame(0, MotionPhotoXmp::fromXml($xml)->legacyOffset);
+ }
+
+ private static function assertNone(MotionPhotoXmp $xmp): void
+ {
+ self::assertFalse($xmp->isMotionPhoto);
+ self::assertSame(0, $xmp->itemLength);
+ self::assertSame(0, $xmp->legacyOffset);
+ }
+
+ private static function item(string $semantic, string $extra = ''): string
+ {
+ return '';
+ }
+
+ private static function motionPhoto(string $items): string
+ {
+ return self::description('GCamera:MotionPhoto="1" GCamera:MotionPhotoVersion="1"', '' . $items . '');
+ }
+
+ private static function description(string $attributes, string $body): string
+ {
+ return '' .
+ '' . $body . '';
+ }
+}
diff --git a/tests/PHPExif/MotionPhoto/ReaderMotionPhotoTest.php b/tests/PHPExif/MotionPhoto/ReaderMotionPhotoTest.php
new file mode 100644
index 0000000..7af0e16
--- /dev/null
+++ b/tests/PHPExif/MotionPhoto/ReaderMotionPhotoTest.php
@@ -0,0 +1,85 @@
+
+ */
+ public static function imageReaders(): array
+ {
+ return [
+ 'native' => [new Native()],
+ 'imagick' => [new ImageMagick()],
+ 'exiftool' => [new Exiftool()],
+ ];
+ }
+
+ #[Group('motionphoto')]
+ #[DataProvider('imageReaders')]
+ public function testMotionPhotoV1(AdapterInterface $adapter): void
+ {
+ $exif = (new Reader($adapter))->read(PHPEXIF_TEST_ROOT . '/files/motion_photo_v1.jpg');
+
+ $this->assertSame(self::VIDEO_LENGTH, $exif->getMicroVideoOffset());
+ }
+
+ #[Group('motionphoto')]
+ #[DataProvider('imageReaders')]
+ public function testUltraHdrMotionPhotoSkipsTheGainMap(AdapterInterface $adapter): void
+ {
+ $exif = (new Reader($adapter))->read(PHPEXIF_TEST_ROOT . '/files/motion_photo_ultrahdr.jpg');
+
+ $this->assertSame(self::VIDEO_LENGTH, $exif->getMicroVideoOffset());
+ }
+
+ #[Group('motionphoto')]
+ #[DataProvider('imageReaders')]
+ public function testPlainPhotoHasNoVideo(AdapterInterface $adapter): void
+ {
+ $exif = (new Reader($adapter))->read(PHPEXIF_TEST_ROOT . '/files/mongolia.jpeg');
+
+ $this->assertFalse($exif->getMicroVideoOffset());
+ }
+
+ #[Group('motionphoto')]
+ public function testAdapterValueIsKept(): void
+ {
+ $adapter = $this->createStub(AdapterInterface::class);
+ $adapter->method('getExifFromFile')->willReturn((new Exif())->setMicroVideoOffset('123'));
+
+ $exif = (new Reader($adapter))->read(PHPEXIF_TEST_ROOT . '/files/motion_photo_v1.jpg');
+
+ $this->assertSame('123', $exif->getMicroVideoOffset());
+ }
+
+ #[Group('motionphoto')]
+ public function testVideosAreLeftAlone(): void
+ {
+ $adapter = $this->createStub(AdapterInterface::class);
+ $adapter->method('getExifFromFile')->willReturn(new Exif());
+
+ $exif = (new Reader($adapter))->read(PHPEXIF_TEST_ROOT . '/files/IMG_3824.MOV');
+
+ $this->assertFalse($exif->getMicroVideoOffset());
+ }
+}
diff --git a/tests/files/motion_photo_ultrahdr.jpg b/tests/files/motion_photo_ultrahdr.jpg
new file mode 100644
index 0000000..4784e0a
Binary files /dev/null and b/tests/files/motion_photo_ultrahdr.jpg differ
diff --git a/tests/files/motion_photo_v1.jpg b/tests/files/motion_photo_v1.jpg
new file mode 100644
index 0000000..0b1bdc4
Binary files /dev/null and b/tests/files/motion_photo_v1.jpg differ