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:
| Field | Holds |
|---|---|
Iptc4xmpExt:DigitalSourceType | the URI of the IPTC term, such as http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia |
xmp:CreatorTool | the generator, where the marking names one |
xmp:CreateDate | the 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.