Skip to main content

Marking images

An image carries its marking inside the file, in its XMP metadata, so it travels wherever the file goes. Pushery\AiContentMarking\Images\ImageMarker writes it there and reads it back, the conversions of spatie/laravel-medialibrary keep it, and MarkingPipeline checks in your own suite that your pipeline does too.

Writing a marking​

use Pushery\AiContentMarking\Images\ImageMarker;
use Pushery\AiContentMarking\Marking;

$marker = new ImageMarker;

$marker->write($path, Marking::generated('Example Image Model')); // a file, in place
$bytes = $marker->mark($bytes, Marking::edited('Example Editor')); // bytes in, bytes out

One field schema serves writing and reading:

FieldHolds
Iptc4xmpExt:DigitalSourceTypethe URI of the IPTC term, such as http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia
xmp:CreatorToolthe generator, where the marking names one
xmp:CreateDatethe moment, where the marking names one

JPEG, PNG and WebP are marked without decoding a pixel. The packet goes into an APP1 segment, an iTXt chunk or an XMP chunk near the start of the file, and an XMP packet the image carried before is replaced. A WebP in the simple format becomes the extended one, which can carry XMP, with the canvas and the alpha of its bitstream. Any other format Imagick reads and writes is marked through Imagick, which encodes the image again.

Every write is read back. An image that does not hold the value afterwards, because its format keeps no XMP, refuses the write with an UnexpectedValueException, and bytes that are no image refuse it with an InvalidArgumentException.

Reading what an image declares​

$read = $marker->read($path); // or $marker->inspect($bytes)

$read->xmp; // the Marking the XMP carries, or null
$read->c2pa; // whether the image embeds or points at a C2PA manifest
$read->c2paSourceType; // the IPTC term the embedded manifest names, or null
$read->isMarked(); // a source type in the XMP, or a C2PA manifest
$read->marksGenerativeAi();

The reader finds the fields in the XMP of any tool, whatever prefix it binds the namespaces to, as attributes, as elements with a text, or as elements that name a resource. The term may be the IPTC URI or the matching schema.org member. It compares values: a packet without a term of the vocabulary is no marking, and neither is the CREATOR: gd-jpeg comment gd writes into a JPEG where the XMP used to be.

It reads the first megabyte of a file, where the header segments that carry provenance live, so the cost does not grow with the file. A WebP that lists its XMP or its C2PA chunk after the image data, as its container recommends, is followed chunk by chunk to those chunks.

A C2PA manifest counts as a marking when the image embeds a manifest store, a JUMBF box labeled c2pa, or names a manifest with a urn:c2pa: reference. The reader does not verify the manifest's signature: the presence of the standardized marker is the signal, and a validator is a separate step for a case that needs one.

Conversions in the media library​

The package extends spatie/laravel-medialibrary. Once a conversion is stored, the package reads its original and writes the marking it declares into the conversion, then reads it back by value. Nothing needs to be registered.

A conversion is a new file. With the gd driver it keeps no XMP at all, and with no driver does it keep a C2PA manifest. An original whose only marking is a C2PA manifest gives its conversions an XMP marking of the source type the manifest names. The manifest itself is not carried over: a copy of it would vouch for pixels it was never signed for. An original that declares nothing leaves its conversions as they are.

A conversion the package cannot write into, such as a format that keeps no XMP, fails the conversion with the exception the marker raised, rather than storing an unmarked copy.

Testing your pipeline​

Pushery\AiContentMarking\Testing\MarkingPipeline runs in your own test suite. Name each stage your images go through, each taking the path of an image and returning the path of the image it made:

use Pushery\AiContentMarking\Testing\MarkingPipeline;

it('keeps the marking from upload to the published version', function () {
MarkingPipeline::through([
'upload' => fn (string $path): string => $this->upload($path),
'thumbnail' => fn (string $path): string => $this->thumbnail($path),
])->assertKeepsMarking();
});

It writes two images, one marked and one not, and sends both through every stage. After each stage the marked image has to carry the source type and the generator it went in with, and the unmarked one no marking at all. The unmarked image is part of the check because a reader that finds a marking everywhere, and a pipeline that marks every file, both look like success from the outside.

A failure is an UnexpectedValueException that names the stage: the one that lost the marking, changed it, marked the unmarked image, or returned no path of an image. Without the imagick extension the helper refuses to run, since no conversion would keep the marking there.