Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Resources/doc/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
$reader = \PHPExif\Reader\Reader::factory(\PHPExif\Enum\ReaderType::NATIVE);
$exif = $reader->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
<?php
$videoLength = \PHPExif\MotionPhoto\MotionPhotoLocator::locate('/path/to/file.jpg'); // 0 if none
```
96 changes: 96 additions & 0 deletions lib/PHPExif/MotionPhoto/FtypBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace PHPExif\MotionPhoto;

use function Safe\fread;
use function Safe\unpack;

/**
* Recognises the `ftyp` box that starts every MP4/QuickTime file:
*
* [uint32 box size][ "ftyp" ][major brand ×4] …
*
* A match needs a plausible box size and a known video brand, so random
* bytes in JPEG image data are very unlikely to pass.
*/
final class FtypBox
{
public const HEADER_LENGTH = 12;
public const CHUNK_SIZE = 65536;

private const TYPE = 'ftyp';
private const MIN_BOX_SIZE = 8;
private const MAX_BOX_SIZE = 256;
private const BRANDS = ['isom', 'iso2', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'avc1', 'qt '];

/**
* @param resource $stream
*/
public static function isAt($stream, int $offset): bool
{
fseek($stream, $offset);

return self::matches(fread($stream, self::HEADER_LENGTH), 0);
}

/**
* Finds the first `ftyp` box at or after `$from`, reading the stream in
* chunks so memory use does not depend on the file size.
*
* @param resource $stream
*
* @return int|null offset of the box in the stream
*/
public static function scan($stream, int $from, int $fileSize): ?int
{
fseek($stream, $from);
$offset = $from;
$carry = '';
while ($offset < $fileSize) {
$chunk = fread($stream, self::CHUNK_SIZE);
if ($chunk === '') {
return null;
}

// The last bytes of the previous chunk are kept, so a box header
// across two chunks is still seen whole.
$buffer = $carry . $chunk;
$hit = self::firstMatch($buffer);
if ($hit !== null) {
return $offset - strlen($carry) + $hit;
}

$carry = substr($buffer, -(self::HEADER_LENGTH - 1));
$offset += strlen($chunk);
}

return null;
}

/**
* @return int|null position in `$buffer` of the first complete box header
*/
private static function firstMatch(string $buffer): ?int
{
$typeAt = strpos($buffer, self::TYPE, 4);
while ($typeAt !== false && !self::matches($buffer, $typeAt - 4)) {
$typeAt = strpos($buffer, self::TYPE, $typeAt + 1);
}

return $typeAt === false ? null : $typeAt - 4;
}

private static function matches(string $bytes, int $at): bool
{
if (strlen($bytes) < $at + self::HEADER_LENGTH) {
return false;
}

$boxSize = unpack('N', $bytes, $at)[1];

return substr($bytes, $at + 4, 4) === self::TYPE &&
in_array(substr($bytes, $at + 8, 4), self::BRANDS, true) &&
$boxSize >= self::MIN_BOX_SIZE &&
$boxSize <= self::MAX_BOX_SIZE;
}
}
19 changes: 19 additions & 0 deletions lib/PHPExif/MotionPhoto/JpegHeader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace PHPExif\MotionPhoto;

/**
* Result of {@see JpegXmpReader::read()}.
*/
final readonly class JpegHeader
{
/**
* @param string|null $xmp the first standard XMP packet, without its namespace prefix
* @param int $imageDataOffset offset of the start-of-scan marker, i.e. where the header ends
*/
public function __construct(
public ?string $xmp,
public int $imageDataOffset,
) {
}
}
51 changes: 51 additions & 0 deletions lib/PHPExif/MotionPhoto/JpegImageEnd.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace PHPExif\MotionPhoto;

use function Safe\fread;

/**
* Finds the end-of-image marker of a JPEG, so that what follows it (gain
* map, motion photo video) can be told apart from the compressed image data.
*
* Inside entropy-coded data every `0xFF` byte is followed by `0x00` (byte
* stuffing) or a restart marker, so the first `FF D9` after the start-of-scan
* marker is the end of the image.
*/
final class JpegImageEnd
{
private const EOI = "\xFF\xD9";

/**
* @param resource $stream
* @param int $from offset of the start-of-scan marker
* @param int $fileSize size of the file in bytes
*
* @return int|null offset just after the EOI marker; null if the image never ends
*/
public static function find($stream, int $from, int $fileSize): ?int
{
fseek($stream, $from);
$offset = $from;
$carry = '';
while ($offset < $fileSize) {
$chunk = fread($stream, FtypBox::CHUNK_SIZE);
if ($chunk === '') {
return null;
}

// The last byte of the previous chunk is kept, so a marker split
// across two chunks is still seen whole.
$buffer = $carry . $chunk;
$eoiAt = strpos($buffer, self::EOI);
if ($eoiAt !== false) {
return $offset - strlen($carry) + $eoiAt + strlen(self::EOI);
}

$carry = substr($buffer, -1);
$offset += strlen($chunk);
}

return null;
}
}
167 changes: 167 additions & 0 deletions lib/PHPExif/MotionPhoto/JpegXmpReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace PHPExif\MotionPhoto;

use function Safe\fread;
use function Safe\unpack;

/**
* Walks the marker segments of a JPEG header, from SOI to SOS, and picks the
* standard XMP packet.
*
* Only the header is read: the walk stops at the start-of-scan marker, so
* the entropy-coded image data and anything appended to the file (gain map,
* motion photo video) are never touched. Every segment length is checked
* against the file size before it is trusted.
*/
final class JpegXmpReader
{
private const SOI = "\xFF\xD8";
private const MARKER_PREFIX = 0xFF;
private const SOS = 0xDA;
private const APP1 = 0xE1;
private const XMP_NAMESPACE = "http://ns.adobe.com/xap/1.0/\x00";

/**
* @param resource $stream readable stream positioned at the start of the file
* @param int $fileSize size of the file in bytes
*
* @return JpegHeader|null null if the stream is not a well-formed JPEG header
*/
public static function read($stream, int $fileSize): ?JpegHeader
{
if (self::readBytes($stream, 2) !== self::SOI) {
return null;
}

$xmp = null;
while (true) {
$marker = self::readMarker($stream);
if ($marker === null) {
return null;
}
if ($marker === self::SOS) {
return new JpegHeader($xmp, self::position($stream) - 2);
}

$payloadLength = self::readPayloadLength($stream, $fileSize);
if ($payloadLength === null) {
return null;
}

$wantsXmp = $marker === self::APP1 && $xmp === null;
$payload = $wantsXmp ? self::readBytes($stream, $payloadLength) : self::skip($stream, $payloadLength);
$xmp = self::xmpOf($payload) ?? $xmp;
}
}

/**
* Reads the next marker code, skipping `0xFF` fill bytes.
*
* @param resource $stream
*
* @return int|null null at the end of the stream or if no marker prefix follows
*/
private static function readMarker($stream): ?int
{
if (self::readByte($stream) !== self::MARKER_PREFIX) {
return null;
}

do {
$code = self::readByte($stream);
} while ($code === self::MARKER_PREFIX);

return $code;
}

/**
* Reads a segment length and returns the length of its payload.
*
* @param resource $stream
*
* @return int|null null if the length is invalid or the payload runs past the end of the file
*/
private static function readPayloadLength($stream, int $fileSize): ?int
{
$bytes = self::readBytes($stream, 2);
if (strlen($bytes) !== 2) {
return null;
}

$segmentLength = unpack('n', $bytes)[1];
if (!is_int($segmentLength)) {
return null;
}

$payloadLength = $segmentLength - 2;
$isValid = $payloadLength >= 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 '';
}
}
Loading
Loading