Skip to content

Commit b656004

Browse files
committed
feat: upgrade to C2PA spec v2.2 and c2patool 0.26
- Update to c2patool v0.26.1 with prebuilt binaries - Replace stds.schema-org.CreativeWork with c2pa.metadata - Update ingredient references to c2pa.ingredient.v3 - Add optional claim_generator_version parameter - Support IPTC and C2PA URI formats for digitalSourceType - Improve EXIF GPS format (degrees,minutes with direction) - Remove unused create_custom_c2pa_manifest function Breaking changes: - Removed create_custom_c2pa_manifest from public API - creator_name is now optional in create_c2pa_manifest
1 parent 5ee7342 commit b656004

File tree

4 files changed

+98
-116
lines changed

4 files changed

+98
-116
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,33 @@ $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
1111

1212
Install c2patool
1313

14+
Download the prebuilt binary for your platform:
15+
16+
```bash
17+
# For macOS (Universal Binary - Apple Silicon & Intel)
18+
curl -L -o c2patool.zip https://github.com/contentauth/c2pa-rs/releases/download/c2patool-v0.26.1/c2patool-v0.26.1-universal-apple-darwin.zip
19+
unzip c2patool.zip
20+
chmod +x c2patool/c2patool
21+
cp c2patool/c2patool ~/.local/bin/ # or any directory in your PATH
22+
23+
# For Linux
24+
curl -L -o c2patool.tar.gz https://github.com/contentauth/c2pa-rs/releases/download/c2patool-v0.26.1/c2patool-v0.26.1-x86_64-unknown-linux-gnu.tar.gz
25+
tar -xzf c2patool.tar.gz
26+
chmod +x c2patool/c2patool
27+
cp c2patool/c2patool ~/.local/bin/
28+
29+
# For Windows
30+
# Download: https://github.com/contentauth/c2pa-rs/releases/download/c2patool-v0.26.1/c2patool-v0.26.1-x86_64-pc-windows-msvc.zip
31+
# Extract and add c2patool.exe to your PATH
32+
33+
# Verify installation
34+
c2patool --version # Should show: c2patool 0.26.1
35+
```
36+
37+
Alternatively, build from source (requires Rust):
38+
1439
```bash
15-
$ cargo install c2patool
40+
$ cargo install c2patool --version 0.26.1
1641
```
1742

1843
Install numbers-c2pa

examples/numbers-c2pa.png

-492 Bytes
Loading

src/numbers_c2pa/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
from .core import (create_c2pa_manifest, create_custom_c2pa_manifest, inject,
2-
inject_file, read_c2pa, read_c2pa_file)
1+
from .core import (create_c2pa_manifest, inject, inject_file, read_c2pa,
2+
read_c2pa_file)
33
from .exceptions import NoClaimFound, UnknownError
44
from .utils import (create_es256_private_key_file,
55
create_self_signed_certificate, generate_es256_private_key)
66

77
__all__ = [
88
'create_c2pa_manifest',
9-
'create_custom_c2pa_manifest',
109
'inject',
1110
'inject_file',
1211
'read_c2pa',

src/numbers_c2pa/core.py

Lines changed: 70 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime
88
from decimal import Decimal
99
from tempfile import TemporaryDirectory
10-
from typing import Any, Dict, List, Optional, Union
10+
from typing import Any, Dict, Optional, Union
1111

1212
import requests
1313

@@ -41,8 +41,17 @@ def format_datetime(date: Optional[datetime], to_timestamp=False) -> Optional[Un
4141
return date.strftime('%Y-%m-%dT%H:%M:%SZ')
4242

4343

44-
def format_geolocation(value: Optional[str]) -> Optional[str]:
45-
return f'{Decimal(value):.12f}' if value else None
44+
def format_geolocation(value: Optional[str], is_latitude: bool) -> Optional[str]:
45+
if not value:
46+
return None
47+
d = Decimal(value)
48+
degrees = int(abs(d))
49+
minutes = (abs(d) - degrees) * 60
50+
if is_latitude:
51+
direction = 'N' if d >= 0 else 'S'
52+
else:
53+
direction = 'E' if d >= 0 else 'W'
54+
return f'{degrees},{minutes:.4f}{direction}'
4655

4756

4857
def c2patool_inject(
@@ -98,76 +107,77 @@ def create_assertion_asset_tree(
98107
}
99108

100109

101-
def create_assertion_creative_work(
110+
def create_assertion_metadata(
102111
nid: str,
103-
creator_name: str,
104112
date_created: Optional[datetime] = None,
105-
location_created: Optional[str] = None,
113+
latitude: Optional[str] = None,
114+
longitude: Optional[str] = None,
115+
date_captured: Optional[datetime] = None,
106116
):
107-
data = {
108-
'@context': 'https://schema.org',
109-
'@type': 'CreativeWork',
110-
'url': f'https://verify.numbersprotocol.io/asset-profile/{nid}',
111-
'identifier': nid,
117+
metadata = {
118+
'@context': {
119+
'dc': 'http://purl.org/dc/elements/1.1/',
120+
'Iptc4xmpCore': 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/',
121+
'Iptc4xmpExt': 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/',
122+
'exif': 'http://ns.adobe.com/exif/1.0/',
123+
'exifEX': 'http://cipa.jp/exif/2.32/',
124+
'tiff': 'http://ns.adobe.com/tiff/1.0/',
125+
'xmp': 'http://ns.adobe.com/xap/1.0/'
126+
},
127+
'dc:identifier': nid,
112128
}
113-
if creator_name:
114-
data['author'] = [
115-
{
116-
'@type': 'Person',
117-
'name': creator_name,
118-
}
119-
]
120129
if isinstance(date_created, datetime):
121-
data['dateCreated'] = date_created.strftime('%Y-%m-%dT%H:%M:%SZ')
122-
if location_created:
123-
data['locationCreated'] = location_created
130+
metadata['dc:date'] = date_created.strftime('%Y-%m-%dT%H:%M:%SZ')
131+
if latitude:
132+
metadata['exif:GPSLatitude'] = format_geolocation(latitude, True)
133+
if longitude:
134+
metadata['exif:GPSLongitude'] = format_geolocation(longitude, False)
135+
if date_captured:
136+
metadata['exif:GPSTimeStamp'] = date_captured.strftime('%H:%M:%S')
137+
metadata['exif:DateTimeOriginal'] = date_captured.strftime('%Y:%m:%d %H:%M:%S')
124138

125139
return {
126-
'label': 'stds.schema-org.CreativeWork',
127-
'data': data
140+
'label': 'c2pa.metadata',
141+
'data': metadata
128142
}
129143

130144

131145
def create_c2pa_manifest(
132146
nid: str,
133-
creator_name: str,
134147
creator_public_key: str,
135148
asset_hash: str,
136149
*,
137150
date_created: Optional[datetime] = None,
138151
latitude: Optional[str] = None,
139152
longitude: Optional[str] = None,
140153
date_captured: Optional[datetime] = None,
154+
creator_name: Optional[str] = None,
141155
alg: str = 'es256',
142156
ta_url: str = 'http://timestamp.digicert.com',
143157
vendor: str = 'numbersprotocol',
144158
claim_generator_name: str = 'Numbers Protocol',
159+
claim_generator_version: Optional[str] = None,
145160
digital_source_type: Optional[str] = None,
146161
generated_by: Optional[str] = None,
147162
asset_tree_cid: Optional[str] = None,
148163
asset_tree_sha256: Optional[str] = None,
149164
asset_tree_signature: Optional[str] = None,
150165
committer: Optional[str] = None,
151166
):
152-
location_created = (
153-
f'{format_geolocation(latitude)}, {format_geolocation(longitude)}'
154-
if latitude and longitude else None
155-
)
167+
claim_generator_info = {'name': claim_generator_name}
168+
if claim_generator_version:
169+
claim_generator_info['version'] = claim_generator_version
156170

157171
manifest = {
158172
'alg': alg,
159173
'ta_url': ta_url,
160174
'vendor': vendor,
161175
'claim_generator': format_claim_generator(claim_generator_name),
162-
'claim_generator_info': [
163-
{
164-
'name': claim_generator_name,
165-
},
166-
],
176+
'claim_generator_info': [claim_generator_info],
167177
'title': nid,
168178
'assertions': [
169-
create_assertion_creative_work(
170-
nid, creator_name, date_created, location_created,
179+
create_assertion_metadata(
180+
nid, date_created, latitude, longitude, date_captured,
171181
),
172182
{
173183
'label': 'c2pa.actions.v2',
@@ -186,26 +196,9 @@ def create_c2pa_manifest(
186196
'publicKey': creator_public_key,
187197
'mediaHash': asset_hash,
188198
'captureTimestamp': format_datetime(date_captured, to_timestamp=True),
199+
**({'creatorName': creator_name} if creator_name else {}),
189200
}
190201
},
191-
{
192-
'label': 'stds.exif',
193-
'data': {
194-
'@context': {
195-
'EXIF': 'http://ns.adobe.com/EXIF/1.0/',
196-
'EXIFEX': 'http://cipa.jp/EXIF/2.32/',
197-
'dc': 'http://purl.org/dc/elements/1.1/',
198-
'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
199-
'tiff': 'http://ns.adobe.com/tiff/1.0/',
200-
'xmp': 'http://ns.adobe.com/xap/1.0/'
201-
},
202-
'EXIF:GPSLatitude': format_geolocation(latitude),
203-
'EXIF:GPSLongitude': format_geolocation(longitude),
204-
"EXIF:GPSTimeStamp": format_datetime(date_captured),
205-
'EXIF:DateTimeOriginal': format_datetime(date_captured),
206-
},
207-
'kind': 'Json'
208-
},
209202
]
210203
}
211204
if assertion_asset_tree := create_assertion_asset_tree(
@@ -215,83 +208,48 @@ def create_c2pa_manifest(
215208
return manifest
216209

217210

218-
def create_custom_c2pa_manifest(
219-
*,
220-
alg: str = 'es256',
221-
ta_url: str = 'http://timestamp.digicert.com',
222-
vendor: str = 'numbersprotocol',
223-
claim_generator: str = 'Numbers_Protocol',
224-
title: Optional[str] = None,
225-
author_type: str = 'Person',
226-
author_credential: Optional[List] = None,
227-
author_identifier: Optional[str] = None,
228-
author_name: Optional[str] = None,
229-
c2pa_actions: Optional[List] = None,
230-
custom_assertions: Optional[List] = None,
231-
):
232-
manifest = {
233-
'alg': alg,
234-
'ta_url': ta_url,
235-
'vendor': vendor,
236-
'claim_generator': claim_generator,
237-
'title': title,
238-
'assertions': [
239-
{
240-
'label': 'stds.schema-org.CreativeWork',
241-
'data': {
242-
'@context': 'https://schema.org',
243-
'@type': 'CreativeWork',
244-
'author': [
245-
{
246-
'@type': author_type,
247-
'credential': author_credential or [],
248-
'identifier': author_identifier,
249-
'name': author_name,
250-
}
251-
],
252-
}
253-
},
254-
]
255-
}
256-
manifest = {k: v for k, v in manifest.items() if v is not None}
257-
if c2pa_actions:
258-
manifest['assertions'].append(
259-
{
260-
'label': 'c2pa.actions',
261-
'data':
262-
{
263-
'actions': c2pa_actions,
264-
}
265-
}
266-
)
267-
if custom_assertions:
268-
manifest['assertions'] += custom_assertions
269-
return manifest
270-
271-
272211
def create_action_c2pa_opened(
273212
asset_hex_hash: str,
274213
digital_source_type: Optional[str] = None,
275214
software_agent: Optional[str] = None,
276215
) -> Dict[str, Any]:
216+
"""Create a c2pa.opened action with ingredient reference.
217+
218+
Args:
219+
asset_hex_hash: Hex-encoded SHA256 hash of the asset
220+
digital_source_type: Digital source type. Can be either:
221+
- Short form: 'trainedAlgorithmicMedia' (IPTC namespace auto-prepended)
222+
- Full URI: 'http://c2pa.org/digitalsourcetype/empty' (used as-is)
223+
software_agent: Name of the software that created/modified the asset
224+
225+
Returns:
226+
Action dictionary with ingredient reference for c2pa.actions.v2
227+
"""
277228
base64_hash = base64.b64encode(binascii.unhexlify(asset_hex_hash)).decode()
229+
230+
# Create action with ingredients reference (plural, as array)
231+
# c2patool 0.26+ uses c2pa.ingredient.v3 label
278232
action = {
279233
'action': 'c2pa.opened',
280234
'parameters': {
281235
'ingredients': [
282236
{
283-
'url': 'self#jumbf=c2pa.assertions/c2pa.ingredient',
237+
'url': 'self#jumbf=c2pa.assertions/c2pa.ingredient.v3',
284238
'alg': 'sha256',
285239
'hash': base64_hash,
286240
},
287241
],
288242
},
289243
}
290244
if digital_source_type:
291-
action['digitalSourceType'] = (
292-
'http://cv.iptc.org/newscodes/digitalsourcetype/'
293-
f'{digital_source_type}'
294-
)
245+
# If full URI provided, use as-is; otherwise prepend IPTC namespace
246+
if digital_source_type.startswith('http://'):
247+
action['digitalSourceType'] = digital_source_type
248+
else:
249+
action['digitalSourceType'] = (
250+
'http://cv.iptc.org/newscodes/digitalsourcetype/'
251+
f'{digital_source_type}'
252+
)
295253
if software_agent:
296254
action['softwareAgent'] = {
297255
'name': software_agent,

0 commit comments

Comments
 (0)