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