Skip to content

Commit c0a573c

Browse files
tmathernTania Mathern
andauthored
docs: Fix ingredient archive linking (#242)
* fix: Docs initial commit * fix: Docs initial cleanup * fix: Docs * fix: Docs * fix: Docs * fix: Docs * fix: Docs --------- Co-authored-by: Tania Mathern <tania.mathern@gmail.comn>
1 parent bd373e1 commit c0a573c

3 files changed

Lines changed: 793 additions & 0 deletions

File tree

docs/selective-manifests.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,45 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader:
557557
new_builder.sign("image/jpeg", source, dest)
558558
```
559559

560+
### Identifying ingredients in archives
561+
562+
When building an ingredient archive, you can set `instance_id` on the ingredient to give it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can be used to look up a specific ingredient from a catalog archive. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin.
563+
564+
`instance_id` is only for identification and catalog lookups. It cannot be used as a linking key in `ingredientIds` when linking ingredient archives to actions — use `label` for that (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)).
565+
566+
```py
567+
# Set instance_id when adding the ingredient to the archive builder.
568+
builder = Builder.from_json(manifest_json)
569+
with open("photo-A.jpg", "rb") as f:
570+
builder.add_ingredient(
571+
{
572+
"title": "photo-A.jpg",
573+
"relationship": "componentOf",
574+
"instance_id": "catalog:photo-A",
575+
},
576+
"image/jpeg",
577+
f,
578+
)
579+
580+
archive = io.BytesIO()
581+
builder.to_archive(archive)
582+
```
583+
584+
Later, when reading the archive, select ingredients by their `instance_id`:
585+
586+
```py
587+
archive.seek(0)
588+
reader = Reader("application/c2pa", archive)
589+
manifest_data = json.loads(reader.json())
590+
active = manifest_data["active_manifest"]
591+
ingredients = manifest_data["manifests"][active]["ingredients"]
592+
593+
for ing in ingredients:
594+
if ing.get("instance_id") == "catalog:photo-A":
595+
# Do something with the found ingredient...
596+
pass
597+
```
598+
560599
### Overriding ingredient properties
561600

562601
When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context:
@@ -731,6 +770,111 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader:
731770
new_builder.sign("image/jpeg", source, dest)
732771
```
733772

773+
### Reading ingredient details from an ingredient archive
774+
775+
An ingredient archive is a serialized `Builder` containing exactly one ingredient (see [Builder archives vs. ingredient archives](#builder-archives-vs-ingredient-archives)). Reading it with `Reader` allows the caller to inspect the ingredient before deciding whether to use it: its thumbnail, whether it carries provenance (e.g. an active manifest), validation status, relationship, etc.
776+
777+
```py
778+
# Open the ingredient archive.
779+
with open("ingredient_archive.c2pa", "rb") as archive_file:
780+
reader = Reader("application/c2pa", archive_file, context=ctx)
781+
parsed = json.loads(reader.json())
782+
active = parsed["active_manifest"]
783+
manifest = parsed["manifests"][active]
784+
785+
# An ingredient archive has exactly one ingredient.
786+
ingredient = manifest["ingredients"][0]
787+
788+
# Relationship e.g. "parentOf", "componentOf", "inputTo".
789+
relationship = ingredient["relationship"]
790+
791+
# Instance ID (optional, can be set by caller).
792+
instance_id = ingredient.get("instance_id")
793+
794+
# Active manifest:
795+
# When present, the ingredient had content credentials itself.
796+
if "active_manifest" in ingredient:
797+
ing_manifest_label = ingredient["active_manifest"]
798+
ing_manifest = parsed["manifests"][ing_manifest_label]
799+
# ing_manifest contains the ingredient's own assertions, actions, etc.
800+
801+
# Validation status.
802+
# The top-level "validation_status" array covers the entire manifest store,
803+
# including this ingredient's manifest.
804+
if "validation_status" in parsed:
805+
for status in parsed["validation_status"]:
806+
print(f"{status['code']}: {status['explanation']}")
807+
808+
# Thumbnail
809+
if "thumbnail" in ingredient:
810+
thumb_id = ingredient["thumbnail"]["identifier"]
811+
with open("thumbnail.jpg", "wb") as thumb_file:
812+
reader.resource_to_stream(thumb_id, thumb_file)
813+
814+
reader.close()
815+
```
816+
817+
#### Linking an archived ingredient to an action
818+
819+
After reading the ingredient details from an ingredient archive, the ingredient can be added to a new `Builder` and linked to an action. You must assign a `label` in the `add_ingredient()` call on the signing builder and use that label as the linking key in `ingredientIds`. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives.
820+
821+
Labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest.
822+
823+
Assign a `label` in the `add_ingredient()` call and reference that same label in `ingredientIds` to link an ingredient to an action.
824+
825+
```py
826+
ctx = Context.from_dict({
827+
"builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}},
828+
"signer": signer,
829+
})
830+
831+
# Read the ingredient archive.
832+
with open("ingredient_archive.c2pa", "rb") as archive_file:
833+
reader = Reader("application/c2pa", archive_file, context=ctx)
834+
parsed = json.loads(reader.json())
835+
active = parsed["active_manifest"]
836+
ingredient = parsed["manifests"][active]["ingredients"][0]
837+
838+
# Use a label as the linking key.
839+
# Any label can be used, as long as it uniquely identifies the link.
840+
manifest_json = {
841+
"claim_generator_info": [{"name": "an-application", "version": "0.1.0"}],
842+
"assertions": [
843+
{
844+
"label": "c2pa.actions.v2",
845+
"data": {
846+
"actions": [
847+
{
848+
"action": "c2pa.opened",
849+
"parameters": {
850+
"ingredientIds": ["archived-ingredient"]
851+
},
852+
}
853+
]
854+
},
855+
}
856+
],
857+
}
858+
859+
with Builder(manifest_json, context=ctx) as builder:
860+
# The label on the ingredient must match the entry in ingredientIds on the action.
861+
archive_file.seek(0)
862+
builder.add_ingredient(
863+
{
864+
"title": ingredient["title"],
865+
"relationship": "parentOf",
866+
"label": "archived-ingredient",
867+
},
868+
"application/c2pa",
869+
archive_file,
870+
)
871+
872+
with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest:
873+
builder.sign("image/jpeg", source, dest)
874+
875+
reader.close()
876+
```
877+
734878
### Merging multiple working stores
735879

736880
> [!NOTE]
@@ -802,3 +946,43 @@ with Builder({
802946
# configure a dedicated Signer explicitly.
803947
builder.sign("image/jpeg", source, dest)
804948
```
949+
950+
## Controlling manifest embedding
951+
952+
By default, `sign()` embeds the manifest directly inside the output asset file.
953+
954+
### Not embedding a manifest store into an asset
955+
956+
Use `set_no_embed()` so the signed asset contains no embedded manifest store. The manifest store bytes are returned from `sign()` and can be stored separately (e.g. as a sidecar file).
957+
958+
```py
959+
ctx = Context.from_dict({
960+
"builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}},
961+
"signer": signer,
962+
})
963+
builder = Builder(manifest_json, context=ctx)
964+
builder.set_no_embed()
965+
builder.set_remote_url("<<URI/URL to remote storage of manifest bytes>>")
966+
967+
with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest:
968+
manifest_bytes = builder.sign("image/jpeg", source, dest)
969+
# manifest_bytes contains the full manifest store.
970+
# Upload manifest_bytes to the remote URL.
971+
# The output asset has no embedded manifest.
972+
```
973+
974+
### Checking manifest location on a Reader
975+
976+
After opening an asset with `Reader`, use `is_embedded()` to check whether the manifest is embedded in the asset or stored remotely. If the manifest is remote, `get_remote_url()` returns the URL it was fetched from (the URL set via `set_remote_url()` at signing time).
977+
978+
```py
979+
reader = Reader("output.jpg", context=ctx)
980+
981+
if reader.is_embedded():
982+
print("Manifest is embedded in the asset.")
983+
else:
984+
print("Manifest is not embedded.")
985+
url = reader.get_remote_url()
986+
if url is not None:
987+
print(f"Remote manifest URL: {url}")
988+
```

docs/working-stores.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,136 @@ with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst:
435435
builder.sign("image/jpeg", src, dst)
436436
```
437437

438+
### Linking an ingredient archive to an action
439+
440+
To link an ingredient archive to an action via `ingredientIds`, you must use a `label` set in the `add_ingredient()` call on the signing builder. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives regardless of where it is set.
441+
442+
```py
443+
import io, json
444+
445+
# Step 1: Create the ingredient archive.
446+
archive_builder = Builder.from_json({
447+
"claim_generator_info": [{"name": "an-application", "version": "0.1.0"}],
448+
"assertions": [],
449+
})
450+
with open("photo.jpg", "rb") as f:
451+
archive_builder.add_ingredient(
452+
{"title": "photo.jpg", "relationship": "componentOf"},
453+
"image/jpeg",
454+
f,
455+
)
456+
archive = io.BytesIO()
457+
archive_builder.to_archive(archive)
458+
archive.seek(0)
459+
460+
# Step 2: Build a manifest with an action that references the ingredient.
461+
manifest_json = {
462+
"claim_generator_info": [{"name": "an-application", "version": "0.1.0"}],
463+
"assertions": [
464+
{
465+
"label": "c2pa.actions.v2",
466+
"data": {
467+
"actions": [
468+
{
469+
"action": "c2pa.placed",
470+
"parameters": {
471+
"ingredientIds": ["my-ingredient"]
472+
},
473+
}
474+
]
475+
},
476+
}
477+
],
478+
}
479+
480+
ctx = Context.from_dict({"signer": signer})
481+
builder = Builder(manifest_json, context=ctx)
482+
483+
# Step 3: Add the ingredient archive with a label matching the ingredientIds value.
484+
# The label MUST be set here, on the signing builder's add_ingredient call.
485+
builder.add_ingredient(
486+
{"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"},
487+
"application/c2pa",
488+
archive,
489+
)
490+
491+
with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst:
492+
builder.sign("image/jpeg", src, dst)
493+
```
494+
495+
When linking multiple ingredient archives, give each a distinct label and reference it in the appropriate action's `ingredientIds` array.
496+
497+
If each ingredient has its own action (e.g., one `c2pa.opened` for the parent and one `c2pa.placed` for a composited element), set up two actions with separate `ingredientIds`:
498+
499+
```py
500+
manifest_json = {
501+
"claim_generator_info": [{"name": "an-application", "version": "0.1.0"}],
502+
"assertions": [{
503+
"label": "c2pa.actions.v2",
504+
"data": {
505+
"actions": [
506+
{
507+
"action": "c2pa.opened",
508+
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation",
509+
"parameters": {"ingredientIds": ["parent-photo"]},
510+
},
511+
{
512+
"action": "c2pa.placed",
513+
"parameters": {"ingredientIds": ["overlay-graphic"]},
514+
},
515+
]
516+
},
517+
}],
518+
}
519+
520+
builder = Builder(manifest_json, context=ctx)
521+
522+
builder.add_ingredient(
523+
{"title": "photo.jpg", "relationship": "parentOf", "label": "parent-photo"},
524+
"application/c2pa",
525+
photo_archive,
526+
)
527+
builder.add_ingredient(
528+
{"title": "overlay.png", "relationship": "componentOf", "label": "overlay-graphic"},
529+
"application/c2pa",
530+
overlay_archive,
531+
)
532+
```
533+
534+
A single `c2pa.placed` action can also reference several `componentOf` ingredients composited together. List all labels in the `ingredientIds` array:
535+
536+
```py
537+
manifest_json = {
538+
"claim_generator_info": [{"name": "an-application", "version": "0.1.0"}],
539+
"assertions": [{
540+
"label": "c2pa.actions.v2",
541+
"data": {
542+
"actions": [{
543+
"action": "c2pa.placed",
544+
"parameters": {
545+
"ingredientIds": ["base-layer", "overlay-layer"]
546+
},
547+
}]
548+
},
549+
}],
550+
}
551+
552+
builder = Builder(manifest_json, context=ctx)
553+
554+
builder.add_ingredient(
555+
{"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"},
556+
"application/c2pa",
557+
base_archive,
558+
)
559+
builder.add_ingredient(
560+
{"title": "overlay.jpg", "relationship": "componentOf", "label": "overlay-layer"},
561+
"application/c2pa",
562+
overlay_archive,
563+
)
564+
```
565+
566+
After signing, the action's `parameters.ingredients` array contains one resolved URL per ingredient.
567+
438568
### Ingredient relationships
439569

440570
Specify the relationship between the ingredient and the current asset:

0 commit comments

Comments
 (0)