From f6b0e07335f23e461ece2e59faf1e6960465275b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 18:26:13 -0700 Subject: [PATCH 1/7] fix: Docs initial commit --- docs/working-stores.md | 72 +++++++++++ tests/test_unit_tests.py | 268 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) diff --git a/docs/working-stores.md b/docs/working-stores.md index c357b8ee..2415b3c4 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -435,6 +435,78 @@ with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: builder.sign("image/jpeg", src, dst) ``` +### Linking an ingredient archive to an action + +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. + +```py +import io, json + +# Step 1: Create the ingredient archive +archive_builder = Builder.from_json({ + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [], +}) +with open("photo.jpg", "rb") as f: + archive_builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf"}, + "image/jpeg", + f, + ) +archive = io.BytesIO() +archive_builder.to_archive(archive) +archive.seek(0) + +# Step 2: Build a manifest with an action that references the ingredient +manifest_json = { + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], +} + +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) + +# Step 3: Add the ingredient archive with a label matching the ingredientIds value. +# The label MUST be set here, on the signing builder's add_ingredient call. +builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, +) + +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +When linking multiple ingredient archives, give each a distinct label and reference them separately in `ingredientIds`: + +```py +builder.add_ingredient( + {"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"}, + "application/c2pa", + base_archive, +) +builder.add_ingredient( + {"title": "overlay.jpg", "relationship": "componentOf", "label": "overlay-layer"}, + "application/c2pa", + overlay_archive, +) +``` + ### Ingredient relationships Specify the relationship between the ingredient and the current asset: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d3ad730f..0769f541 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -4152,6 +4152,274 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + # ----------------------------------------------------------------------- + # Tests: Linking ingredient archives to actions. + # + # Only labels set on the signing builder's add_ingredient call work for + # linking ingredient archives to actions via ingredientIds. + # Labels baked into the archive and instance_id (anywhere) do NOT work. + # ----------------------------------------------------------------------- + + def _create_ingredient_archive(self, ingredient_json=None): + """Helper: create an ingredient archive from a single ingredient.""" + if ingredient_json is None: + ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + archive.seek(0) + return archive + + def test_link_archive_label_on_signing_builder_placed(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.placed action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive() + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + placed_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_label_on_signing_builder_opened(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.opened action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive( + {"title": "photo.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.opened": + opened_action = action + break + + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + self.assertIn("parameters", opened_action) + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + opened_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_two_ingredients_labels(self): + """Two ingredient archives linked to two different actions via + distinct labels. Verifies no cross-linking.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "photo-placed.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "photo-opened.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["ingredient-for-placed"] + }, + }, + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["ingredient-for-opened"] + }, + }, + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "photo-opened.jpg", "relationship": "parentOf", "label": "ingredient-for-opened"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + if action["action"] == "c2pa.opened": + opened_action = action + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + placed_url = placed_action["parameters"]["ingredients"][0]["url"] + + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + opened_url = opened_action["parameters"]["ingredients"][0]["url"] + + # Each action should link to a different ingredient (no cross-linking) + self.assertNotEqual(placed_url, opened_url, + "Each action should link to a different ingredient") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + class TestStream(unittest.TestCase): def setUp(self): self.temp_file = io.BytesIO() From ef77a0bbd6078ac09f4b88e0462470598449efb0 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:20:09 -0700 Subject: [PATCH 2/7] fix: Docs initial cleanup --- docs/working-stores.md | 60 +++++++++++++++- tests/test_unit_tests.py | 148 +++++++++++++++++++++++++++++---------- 2 files changed, 169 insertions(+), 39 deletions(-) diff --git a/docs/working-stores.md b/docs/working-stores.md index 2415b3c4..84e010f8 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -492,9 +492,65 @@ with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: builder.sign("image/jpeg", src, dst) ``` -When linking multiple ingredient archives, give each a distinct label and reference them separately in `ingredientIds`: +When linking multiple ingredient archives, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. + +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`: ```py +manifest_json = { + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": {"ingredientIds": ["parent-photo"]}, + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay-graphic"]}, + }, + ] + }, + }], +} + +builder = Builder(manifest_json, context=ctx) + +builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "label": "parent-photo"}, + "application/c2pa", + photo_archive, +) +builder.add_ingredient( + {"title": "overlay.png", "relationship": "componentOf", "label": "overlay-graphic"}, + "application/c2pa", + overlay_archive, +) +``` + +A single `c2pa.placed` action can also reference several `componentOf` ingredients composited together. List all labels in the `ingredientIds` array: + +```py +manifest_json = { + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + }, + }] + }, + }], +} + +builder = Builder(manifest_json, context=ctx) + builder.add_ingredient( {"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"}, "application/c2pa", @@ -507,6 +563,8 @@ builder.add_ingredient( ) ``` +After signing, the action's `parameters.ingredients` array contains one resolved URL per ingredient. + ### Ingredient relationships Specify the relationship between the ingredient and the current asset: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0769f541..0f809b80 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1021,6 +1021,35 @@ def callback_signer_es256(data: bytes) -> bytes: return signature self.callback_signer_es256 = callback_signer_es256 + def _create_ingredient_archive(self, ingredient_json=None): + """Helper: create an ingredient archive from a single ingredient.""" + if ingredient_json is None: + ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + archive.seek(0) + return archive + def test_can_retrieve_builder_supported_mimetypes(self): result1 = Builder.get_supported_mime_types() self.assertTrue(len(result1) > 0) @@ -4151,44 +4180,6 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): # Make sure settings are put back to the common test defaults load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - # ----------------------------------------------------------------------- - # Tests: Linking ingredient archives to actions. - # - # Only labels set on the signing builder's add_ingredient call work for - # linking ingredient archives to actions via ingredientIds. - # Labels baked into the archive and instance_id (anywhere) do NOT work. - # ----------------------------------------------------------------------- - - def _create_ingredient_archive(self, ingredient_json=None): - """Helper: create an ingredient archive from a single ingredient.""" - if ingredient_json is None: - ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - } - ] - }, - } - ], - } - builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - archive.seek(0) - return archive - def test_link_archive_label_on_signing_builder_placed(self): """Label set on the signing builder's add_ingredient links an ingredient archive to a c2pa.placed action.""" @@ -4419,6 +4410,87 @@ def test_link_archive_two_ingredients_labels(self): load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + def test_link_archive_multiple_ingredients_in_one_placed_action(self): + """A single c2pa.placed action references two componentOf ingredients + via ingredientIds with two labels.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "base-layer.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "overlay-layer.jpg", "relationship": "componentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "overlay-layer.jpg", "relationship": "componentOf", "label": "overlay-layer"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + ingredients = placed_action["parameters"]["ingredients"] + self.assertEqual(len(ingredients), 2, + "c2pa.placed should reference both ingredients") + + url0 = ingredients[0]["url"] + url1 = ingredients[1]["url"] + self.assertNotEqual(url0, url1, + "Each ingredient should have a distinct URL") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + class TestStream(unittest.TestCase): def setUp(self): From 696ce95027eb277e0b6635fec49dc8386767311d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:29:21 -0700 Subject: [PATCH 3/7] fix: Docs --- docs/selective-manifests.md | 37 ++++++++++ tests/test_unit_tests.py | 139 ++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 3fd2d538..f61a3d7c 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -557,6 +557,43 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: new_builder.sign("image/jpeg", source, dest) ``` +### 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. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. + +```py +# Set instance_id when adding the ingredient to the archive builder +builder = Builder.from_json(manifest_json) +with open("photo-A.jpg", "rb") as f: + builder.add_ingredient( + { + "title": "photo-A.jpg", + "relationship": "componentOf", + "instance_id": "catalog:photo-A", + }, + "image/jpeg", + f, + ) + +archive = io.BytesIO() +builder.to_archive(archive) +``` + +Later, when reading the archive, select ingredients by their `instance_id`: + +```py +archive.seek(0) +reader = Reader("application/c2pa", archive) +manifest_data = json.loads(reader.json()) +active = manifest_data["active_manifest"] +ingredients = manifest_data["manifests"][active]["ingredients"] + +for ing in ingredients: + if ing.get("instance_id") == "catalog:photo-A": + # Found the target ingredient + pass +``` + ### Overriding ingredient properties 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: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0f809b80..4d1a79af 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -4491,6 +4491,145 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + def test_ingredient_fields_survive_archive(self): + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertEqual(ing["title"], "tracked-asset.jpg") + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + archive.close() + + def test_ingredient_fields_survive_archive_then_sign(self): + """instance_id set on the archive ingredient persists through + archive then sign.""" + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "tracked-asset.jpg", "relationship": "componentOf"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + output.close() + archive.close() + builder.close() + + def test_instance_id_as_ingredient_identifier_in_catalog(self): + """Two ingredients with different instance_id values in one archive. + Read the archive back and select an ingredient by instance_id.""" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-A.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-A"}, + "image/jpeg", f, + ) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-B.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-B"}, + "image/jpeg", f, + ) + + archive = io.BytesIO() + builder.to_archive(archive) + archive.seek(0) + builder.close() + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertEqual(len(ingredients), 2) + + found = None + for ing in ingredients: + if ing.get("instance_id") == "catalog:photo-B": + found = ing + break + + self.assertIsNotNone(found, + "Should find ingredient by instance_id 'catalog:photo-B' in archive") + self.assertEqual(found["title"], "photo-B.jpg") + + reader.close() + archive.close() + class TestStream(unittest.TestCase): def setUp(self): From fca1a962d909d24391ea792a48a000a6e5da9488 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:44:26 -0700 Subject: [PATCH 4/7] fix: Docs --- docs/selective-manifests.md | 164 +++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index f61a3d7c..e8023373 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -559,7 +559,9 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: ### 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. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. +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. + +`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)). ```py # Set instance_id when adding the ingredient to the archive builder @@ -590,7 +592,7 @@ ingredients = manifest_data["manifests"][active]["ingredients"] for ing in ingredients: if ing.get("instance_id") == "catalog:photo-A": - # Found the target ingredient + # Do something with the found ingredient... pass ``` @@ -768,6 +770,121 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: new_builder.sign("image/jpeg", source, dest) ``` +### Reading ingredient details from an ingredient archive + +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. + +```mermaid +flowchart LR + IA["ingredient_archive.c2pa"] -->|"Reader(application/c2pa)"| JSON["JSON + resources"] + JSON --> TH["Thumbnail"] + JSON --> AM["Active manifest?"] + JSON --> VS["Validation status"] + JSON --> REL["Relationship"] +``` + +```py +# Open the ingredient archive +with open("ingredient_archive.c2pa", "rb") as archive_file: + reader = Reader("application/c2pa", archive_file, context=ctx) + parsed = json.loads(reader.json()) + active = parsed["active_manifest"] + manifest = parsed["manifests"][active] + + # An ingredient archive has exactly one ingredient + ingredient = manifest["ingredients"][0] + + # Relationship + relationship = ingredient["relationship"] # e.g. "parentOf", "componentOf", "inputTo" + + # Instance ID (optional, set by the caller via add_ingredient or derived from XMP metadata) + instance_id = ingredient.get("instance_id") + + # Active manifest: + # When present, the ingredient was a signed asset and its manifest label + # points into the top-level "manifests" dictionary. + if "active_manifest" in ingredient: + ing_manifest_label = ingredient["active_manifest"] + ing_manifest = parsed["manifests"][ing_manifest_label] + # ing_manifest contains the ingredient's own assertions, actions, etc. + + # Validation status. + # The top-level "validation_status" array covers the entire manifest store, + # including this ingredient's manifest. An empty or absent array means + # no validation errors were found. + if "validation_status" in parsed: + for status in parsed["validation_status"]: + print(f"{status['code']}: {status['explanation']}") + + # Thumbnail + if "thumbnail" in ingredient: + thumb_id = ingredient["thumbnail"]["identifier"] + with open("thumbnail.jpg", "wb") as thumb_file: + reader.resource_to_stream(thumb_id, thumb_file) + + reader.close() +``` + +#### Linking an archived ingredient to an action + +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. + +Labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest. + +Assign a `label` in the `add_ingredient()` call and reference that same label in `ingredientIds`. This works whether or not the ingredient has an `instance_id`. + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +# Read the ingredient archive +with open("ingredient_archive.c2pa", "rb") as archive_file: + reader = Reader("application/c2pa", archive_file, context=ctx) + parsed = json.loads(reader.json()) + active = parsed["active_manifest"] + ingredient = parsed["manifests"][active]["ingredients"][0] + + # Use a caller-assigned label as the linking key + manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredientIds": ["archived-ingredient"] + }, + } + ] + }, + } + ], + } + + with Builder(manifest_json, context=ctx) as builder: + # The label on the ingredient matches the entry in ingredientIds + archive_file.seek(0) + builder.add_ingredient( + { + "title": ingredient["title"], + "relationship": "parentOf", + "label": "archived-ingredient", + }, + "application/c2pa", + archive_file, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: + builder.sign("image/jpeg", source, dest) + + reader.close() +``` + ### Merging multiple working stores > [!NOTE] @@ -839,3 +956,46 @@ with Builder({ # configure a dedicated Signer explicitly. builder.sign("image/jpeg", source, dest) ``` + +## Controlling manifest embedding + +By default, `sign()` embeds the manifest directly inside the output asset file. + +### Remove the manifest from the asset entirely + +Use `set_no_embed()` so the signed asset contains no embedded manifest. The manifest bytes are returned from `sign()` and can be stored separately (as a sidecar file, on a server, etc.): + +```mermaid +flowchart LR + subgraph Default["Default (embedded)"] + A1[Output Asset] --- A2[Image data + C2PA manifest] + end + + subgraph NoEmbed["With set_no_embed()"] + B1[Output Asset] ~~~ B2[Manifest bytes with store as sidecar or uploaded to server] + end +``` + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) +builder = Builder(manifest_json, context=ctx) +builder.set_no_embed() +builder.set_remote_url("<>") + +with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: + manifest_bytes = builder.sign("image/jpeg", source, dest) + # manifest_bytes contains the full manifest store + # Upload manifest_bytes to the remote URL + # The output asset has no embedded manifest +``` + +Reading back: + +```py +reader = Reader("output.jpg", context=ctx) +reader.is_embedded() # False +reader.remote_url() # "<>" +``` From d4282ece7c3c21ae587bfe9cd179dfe12708e399 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:55:13 -0700 Subject: [PATCH 5/7] fix: Docs --- docs/selective-manifests.md | 48 ++++++++++++++----------------------- docs/working-stores.md | 4 ++-- 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index e8023373..f5387fa7 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -564,7 +564,7 @@ When building an ingredient archive, you can set `instance_id` on the ingredient `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)). ```py -# Set instance_id when adding the ingredient to the archive builder +# Set instance_id when adding the ingredient to the archive builder. builder = Builder.from_json(manifest_json) with open("photo-A.jpg", "rb") as f: builder.add_ingredient( @@ -778,31 +778,30 @@ An ingredient archive is a serialized `Builder` containing exactly one ingredien flowchart LR IA["ingredient_archive.c2pa"] -->|"Reader(application/c2pa)"| JSON["JSON + resources"] JSON --> TH["Thumbnail"] - JSON --> AM["Active manifest?"] + JSON --> AM["Active manifest (only if ingredient has Content Credentials)"] JSON --> VS["Validation status"] JSON --> REL["Relationship"] ``` ```py -# Open the ingredient archive +# Open the ingredient archive. with open("ingredient_archive.c2pa", "rb") as archive_file: reader = Reader("application/c2pa", archive_file, context=ctx) parsed = json.loads(reader.json()) active = parsed["active_manifest"] manifest = parsed["manifests"][active] - # An ingredient archive has exactly one ingredient + # An ingredient archive has exactly one ingredient. ingredient = manifest["ingredients"][0] - # Relationship - relationship = ingredient["relationship"] # e.g. "parentOf", "componentOf", "inputTo" + # Relationship e.g. "parentOf", "componentOf", "inputTo". + relationship = ingredient["relationship"] - # Instance ID (optional, set by the caller via add_ingredient or derived from XMP metadata) + # Instance ID (optional, can be set by caller). instance_id = ingredient.get("instance_id") # Active manifest: - # When present, the ingredient was a signed asset and its manifest label - # points into the top-level "manifests" dictionary. + # When present, the ingredient had content credentials itself. if "active_manifest" in ingredient: ing_manifest_label = ingredient["active_manifest"] ing_manifest = parsed["manifests"][ing_manifest_label] @@ -810,8 +809,7 @@ with open("ingredient_archive.c2pa", "rb") as archive_file: # Validation status. # The top-level "validation_status" array covers the entire manifest store, - # including this ingredient's manifest. An empty or absent array means - # no validation errors were found. + # including this ingredient's manifest. if "validation_status" in parsed: for status in parsed["validation_status"]: print(f"{status['code']}: {status['explanation']}") @@ -831,7 +829,7 @@ After reading the ingredient details from an ingredient archive, the ingredient Labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest. -Assign a `label` in the `add_ingredient()` call and reference that same label in `ingredientIds`. This works whether or not the ingredient has an `instance_id`. +Assign a `label` in the `add_ingredient()` call and reference that same label in `ingredientIds` to link an ingredient to an action. ```py ctx = Context.from_dict({ @@ -839,14 +837,15 @@ ctx = Context.from_dict({ "signer": signer, }) -# Read the ingredient archive +# Read the ingredient archive. with open("ingredient_archive.c2pa", "rb") as archive_file: reader = Reader("application/c2pa", archive_file, context=ctx) parsed = json.loads(reader.json()) active = parsed["active_manifest"] ingredient = parsed["manifests"][active]["ingredients"][0] - # Use a caller-assigned label as the linking key + # Use a label as the linking key. + # Any label can be used, as long as it uniquely identifies the link. manifest_json = { "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ @@ -867,7 +866,7 @@ with open("ingredient_archive.c2pa", "rb") as archive_file: } with Builder(manifest_json, context=ctx) as builder: - # The label on the ingredient matches the entry in ingredientIds + # The label on the ingredient must match the entry in ingredientIds on the action. archive_file.seek(0) builder.add_ingredient( { @@ -963,18 +962,7 @@ By default, `sign()` embeds the manifest directly inside the output asset file. ### Remove the manifest from the asset entirely -Use `set_no_embed()` so the signed asset contains no embedded manifest. The manifest bytes are returned from `sign()` and can be stored separately (as a sidecar file, on a server, etc.): - -```mermaid -flowchart LR - subgraph Default["Default (embedded)"] - A1[Output Asset] --- A2[Image data + C2PA manifest] - end - - subgraph NoEmbed["With set_no_embed()"] - B1[Output Asset] ~~~ B2[Manifest bytes with store as sidecar or uploaded to server] - end -``` +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). ```py ctx = Context.from_dict({ @@ -987,9 +975,9 @@ builder.set_remote_url("<>") with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: manifest_bytes = builder.sign("image/jpeg", source, dest) - # manifest_bytes contains the full manifest store - # Upload manifest_bytes to the remote URL - # The output asset has no embedded manifest + # manifest_bytes contains the full manifest store. + # Upload manifest_bytes to the remote URL. + # The output asset has no embedded manifest. ``` Reading back: diff --git a/docs/working-stores.md b/docs/working-stores.md index 84e010f8..f9ddec31 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -442,7 +442,7 @@ To link an ingredient archive to an action via `ingredientIds`, you must use a ` ```py import io, json -# Step 1: Create the ingredient archive +# Step 1: Create the ingredient archive. archive_builder = Builder.from_json({ "claim_generator_info": [{"name": "my-app", "version": "1.0"}], "assertions": [], @@ -457,7 +457,7 @@ archive = io.BytesIO() archive_builder.to_archive(archive) archive.seek(0) -# Step 2: Build a manifest with an action that references the ingredient +# Step 2: Build a manifest with an action that references the ingredient. manifest_json = { "claim_generator_info": [{"name": "my-app", "version": "1.0"}], "assertions": [ From 27022ca8b10b6f031ac65e7f5fbaa1471d8f8507 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 20:00:41 -0700 Subject: [PATCH 6/7] fix: Docs --- docs/selective-manifests.md | 25 ++++++++++++------------- docs/working-stores.md | 8 ++++---- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index f5387fa7..885c3a13 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -774,15 +774,6 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: 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. -```mermaid -flowchart LR - IA["ingredient_archive.c2pa"] -->|"Reader(application/c2pa)"| JSON["JSON + resources"] - JSON --> TH["Thumbnail"] - JSON --> AM["Active manifest (only if ingredient has Content Credentials)"] - JSON --> VS["Validation status"] - JSON --> REL["Relationship"] -``` - ```py # Open the ingredient archive. with open("ingredient_archive.c2pa", "rb") as archive_file: @@ -960,7 +951,7 @@ with Builder({ By default, `sign()` embeds the manifest directly inside the output asset file. -### Remove the manifest from the asset entirely +### Not embedding a manifest store into an asset 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). @@ -980,10 +971,18 @@ with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: # The output asset has no embedded manifest. ``` -Reading back: +### Checking manifest location on a Reader + +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 (or the URL set via `set_remote_url()` at signing time). ```py reader = Reader("output.jpg", context=ctx) -reader.is_embedded() # False -reader.remote_url() # "<>" + +if reader.is_embedded(): + print("Manifest is embedded in the asset.") +else: + print("Manifest is not embedded.") + url = reader.get_remote_url() + if url is not None: + print(f"Remote manifest URL: {url}") ``` diff --git a/docs/working-stores.md b/docs/working-stores.md index f9ddec31..39e56c85 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -444,7 +444,7 @@ import io, json # Step 1: Create the ingredient archive. archive_builder = Builder.from_json({ - "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [], }) with open("photo.jpg", "rb") as f: @@ -459,7 +459,7 @@ archive.seek(0) # Step 2: Build a manifest with an action that references the ingredient. manifest_json = { - "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ { "label": "c2pa.actions.v2", @@ -498,7 +498,7 @@ If each ingredient has its own action (e.g., one `c2pa.opened` for the parent an ```py manifest_json = { - "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [{ "label": "c2pa.actions.v2", "data": { @@ -535,7 +535,7 @@ A single `c2pa.placed` action can also reference several `componentOf` ingredien ```py manifest_json = { - "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [{ "label": "c2pa.actions.v2", "data": { From 1381f7a93aa08ff1259627cd8b9e505a7c09d65c Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 20:01:42 -0700 Subject: [PATCH 7/7] fix: Docs --- docs/selective-manifests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 885c3a13..37bb0eb7 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -973,7 +973,7 @@ with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: ### Checking manifest location on a Reader -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 (or the URL set via `set_remote_url()` at signing time). +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). ```py reader = Reader("output.jpg", context=ctx)