From a14ee205669b9b2629e8ade373fac27069327c6f Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:12:57 -0700 Subject: [PATCH] fix: Add bindings --- docs/selective-manifests.md | 379 ++++++++++++++++++++++++++++++ docs/working-stores.md | 444 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +- src/c2pa/c2pa.py | 59 +++++ tests/test_unit_tests.py | 353 ++++++++++++++++++++++++++++ 5 files changed, 1238 insertions(+), 2 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 37bb0eb7..940f8ca8 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -494,6 +494,17 @@ An **ingredient archive** contains the manifest store from an asset that was add The key difference: a builder archive is a work-in-progress (unsigned). An ingredient archive carries the provenance history of a source asset for reuse as an ingredient in other working stores. +### Producing an ingredient archive + +The SDK supports two approaches for producing an ingredient archive. They share the same `.c2pa` binary format and are interchangeable from the consumer side. + +| Approach | Entry point | Status | +| --- | --- | --- | +| Dedicated ingredient archive APIs | `add_ingredient` then `write_ingredient_archive(id, stream)` | **Recommended** | +| Read-filter-rebuild pattern | `Builder` + `add_ingredient` + `to_archive`, then `Reader` + manual JSON | **Older pattern** | + +For the full contract, see [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. + ### The ingredients catalog pattern An **ingredients catalog** is a collection of archived ingredients that can be selected when constructing a final manifest. Each archive holds ingredients; at build time the caller selects only the ones needed. @@ -557,6 +568,132 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: new_builder.sign("image/jpeg", source, dest) ``` +### Dedicated archives API: one ingredient per archive + +The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id` as unique identifier. The consumer assembles a final Builder instance by loading only the archives it needs via `add_ingredient_from_archive`. + +The first argument to `write_ingredient_archive` is the *archive key*: it locates the ingredient on the producer (matched against either `label` or `instance_id`) and becomes the `ingredientIds` value to use on the signing builder. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking) for the full rules. + +> [!NOTE] +> `"relationship": "componentOf"` is shown explicitly below, but `componentOf` is the default the SDK applies when `relationship` is omitted. + +Producer side, build the catalog: + +```py +import io +from c2pa import Builder + +catalog_builder = Builder.from_json(manifest_json) +with open("photo-A.jpg", "rb") as f: + catalog_builder.add_ingredient( + {"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"}, + "image/jpeg", f + ) +with open("photo-B.jpg", "rb") as f: + catalog_builder.add_ingredient( + {"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"}, + "image/jpeg", f + ) +with open("photo-C.jpg", "rb") as f: + catalog_builder.add_ingredient( + {"title": "photo-C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"}, + "image/jpeg", f + ) + +# One archive per ingredient, keyed by the instance_id used at registration. +archive_a, archive_b, archive_c = io.BytesIO(), io.BytesIO(), io.BytesIO() +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a) +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b) +catalog_builder.write_ingredient_archive("catalog:ingredient-C", archive_c) +``` + +Consumer side, pick one archive and load it: + +```py +final_builder = Builder.from_json(manifest_json) +archive_b.seek(0) +final_builder.add_ingredient_from_archive(archive_b) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + final_builder.sign(signer, "image/jpeg", src, dst) +``` + +The signed output contains exactly the picked ingredient (`photo-B.jpg` here). `archive_a` stays unused. + +A single action can link several ingredients loaded this way. With the three archives from the producer above, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: + +```py +signing_manifest = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["catalog:ingredient-A", "catalog:ingredient-B", "catalog:ingredient-C"] + } + }] + } + }] +} + +signing_builder = Builder.from_json(signing_manifest) +for archive in (archive_a, archive_b, archive_c): + archive.seek(0) + signing_builder.add_ingredient_from_archive(archive) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + signing_builder.sign(signer, "image/jpeg", src, dst) +``` + +### Legacy catalog: read-filter-rebuild APIs + +> [!NOTE] +> **Legacy approach.** This pattern requires manual JSON parsing and `add_resource` loops to transfer binary data related to ingredients. See [Migration guide](#migration-guide-catalog-pattern) to use the [dedicated ingredient archive APIs](#dedicated-archives-api-one-ingredient-per-archive) instead. + +Use this approach when the catalog already exists as a single `.c2pa` builder archive containing many ingredients and you need to pick a subset by reading, filtering, and rebuilding. + +#### Migration guide: catalog pattern + +Switch to the dedicated ingredient archive APIs: set `instance_id` per ingredient, call `write_ingredient_archive` once per ingredient on the producer, and `add_ingredient_from_archive` on the consumer. No JSON parsing or `add_resource` loops required. + +Producer side: + +```py +catalog_builder = Builder.from_json(manifest_json) +with open("photo-A.jpg", "rb") as f: + catalog_builder.add_ingredient( + {"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"}, + "image/jpeg", f + ) +with open("photo-B.jpg", "rb") as f: + catalog_builder.add_ingredient( + {"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"}, + "image/jpeg", f + ) + +archive_a, archive_b = io.BytesIO(), io.BytesIO() +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a) +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b) +``` + +Consumer side: + +```py +final_builder = Builder.from_json(manifest_json) +archive_b.seek(0) +final_builder.add_ingredient_from_archive(archive_b) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + final_builder.sign(signer, "image/jpeg", src, dst) +``` + +Action linking also changes between the two approaches. Legacy catalog code linked ingredients via `label` set on the signing builder's `add_ingredient` JSON; `instance_id` was not accepted. The dedicated archive API accepts the archive key passed to `write_ingredient_archive`, which can be either `label` or `instance_id`. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). + +#### Choosing between approaches + +The legacy read-filter-rebuild APIs fit when the catalog already exists as one multi-ingredient builder archive and the consumer wants a subset of it. The dedicated ingredient archive APIs fit when ingredients are produced and consumed independently: each archive holds exactly one ingredient, and the call sites stay short. Both produce the same signed output. + ### Identifying ingredients in archives 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. @@ -875,6 +1012,26 @@ with open("ingredient_archive.c2pa", "rb") as archive_file: reader.close() ``` +#### Troubleshooting ingredients to actions linking errors + +A signing-time error when linking ingredients to actions failed is: + +```text +Builder.sign failure: Other: assertion-specific error: +Action ingredientId not found: +``` + +Causes and potential fixes to investigate: + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `Action ingredientId not found: xmp:iid:...` (or any `instance_id` value) | `instance_id` was used as the linking key for an ingredient archive loaded via the legacy path. | Assign a `label` on the signing builder's `add_ingredient` JSON and use that label in `ingredientIds`. | +| `Action ingredientId not found: