Live video on the AT Protocol

Merge pull request #775 from streamplace/c2pa-metadata

Make C2PA manifest more idiomatic (in-repo version)

authored by

Eli Mallon and committed by
GitHub
95ccb900 80f92d68

+116 -122
+1
Makefile
··· 309 309 go install github.com/jstemmer/go-junit-report/v2@latest \ 310 310 && PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) \ 311 311 LD_LIBRARY_PATH=$(shell realpath $(BUILDDIR))/lib \ 312 + CGO_LDFLAGS="-lm" \ 312 313 bash -euo pipefail -c "go test -p 1 -timeout 300s ./pkg/... -v | tee /dev/stderr | go-junit-report -out test.xml" 313 314 314 315 .PHONY: iroh-test
+37 -28
js/docs/src/content/docs/video-metadata/c2pa-integration.md
··· 17 17 18 18 ```json 19 19 { 20 - "active_manifest": "urn:c2pa:b23b55a7-bd34-4138-99d8-ce565fab3934", 20 + "active_manifest": "urn:c2pa:66130743-a4cd-4f73-a4c7-201079ef127a", 21 21 "manifests": { 22 - "urn:c2pa:b23b55a7-bd34-4138-99d8-ce565fab3934": { 22 + "urn:c2pa:66130743-a4cd-4f73-a4c7-201079ef127a": { 23 23 "claim_generator_info": [ 24 24 { 25 25 "name": "c2pa-rs", ··· 27 27 "org.contentauth.c2pa_rs": "0.58.0" 28 28 } 29 29 ], 30 - "title": "Livestream Segment at 2025-10-21T19:24:24.156Z", 31 - "instance_id": "xmp:iid:17f3177c-7cfe-4de2-9a23-019dcdb00559", 30 + "title": "Livestream Segment at 2025-11-13T20:41:30.499Z", 31 + "instance_id": "xmp:iid:a031a30d-e1eb-4548-a20f-6453e15c2ace", 32 32 "ingredients": [], 33 33 "assertions": [ 34 34 { ··· 36 36 "data": { 37 37 "actions": [ 38 38 { 39 - "action": "c2pa.created" 39 + "action": "c2pa.created", 40 + "when": "2025-11-13T20:41:30.499Z" 40 41 }, 41 42 { 42 - "action": "c2pa.published" 43 + "action": "c2pa.published", 44 + "when": "2025-11-13T20:41:30.499Z" 43 45 } 44 46 ], 45 47 "allActionsIncluded": false ··· 83 85 } 84 86 ], 85 87 "alg": "sha256", 86 - "hash": "HrLwGm+HdaZh9TkBiWhJH1Mo7QcvLgmhMThcG8f3qZc=", 88 + "hash": "ZHobn5CtL1NsTLAMMPT8D7koSrMr2Ijm5eJt73ED4HA=", 87 89 "name": "jumbf manifest" 88 90 } 89 91 }, 90 92 { 91 - "label": "place.stream.metadata", 93 + "label": "cawg.metadata", 92 94 "data": { 93 95 "@context": { 94 96 "photoshop": "http://ns.adobe.com/photoshop/1.0/", 95 - "dc": "http://purl.org/dc/elements/1.1/", 96 97 "xmpRights": "http://ns.adobe.com/xap/1.0/rights/", 97 - "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/" 98 + "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", 99 + "dc": "http://purl.org/dc/elements/1.1/" 98 100 }, 99 - "dc:creator": "did:plc:y3lae7hmqiwyq7w2v3bcb2c2", 100 - "dc:title": ["🦎🦎"], 101 - "dc:date": ["2025-10-21T19:24:24.156Z"], 102 - "distributionPolicy": { 103 - "deleteAfter": "2025-10-21T19:29:24.000Z" 104 - }, 105 - "Iptc4xmpExt:LinkedEncRightsExpr": "http://creativecommons.org/publicdomain/zero/1.0/" 101 + "xmpRights:UsageTerms": "All rights reserved", 102 + "dc:creator": "did:plc:2j2ounbiyi3ftihronlw5qhj", 103 + "dc:date": "2025-11-13T20:41:30.499Z", 104 + "Iptc4xmpExt:ContentWarning": ["cwarn:flashingLights"], 105 + "dc:title": "Test" 106 106 }, 107 107 "kind": "Json" 108 108 }, ··· 111 111 "data": { 112 112 "$type": "place.stream.metadata.configuration", 113 113 "contentRights": { 114 - "license": "place.stream.metadata.contentRights#cc0_1__0" 114 + "license": "place.stream.metadata.contentRights#all-rights-reserved" 115 + }, 116 + "contentWarnings": { 117 + "warnings": ["cwarn:flashingLights"] 115 118 }, 116 119 "distributionPolicy": { 117 120 "deleteAfter": 300 ··· 121 124 { 122 125 "label": "place.stream.livestream", 123 126 "data": { 124 - "url": "https://picnic-labs-nicholas-not.trycloudflare.com", 127 + "url": "https://headphones-glad-thunder-guide.trycloudflare.com", 125 128 "post": { 126 - "cid": "bafyreicucf722xnyf74psia5ghd5usdnona4e7bkcgbqhdma2a6dokqh5m", 127 - "uri": "at://did:plc:y3lae7hmqiwyq7w2v3bcb2c2/app.bsky.feed.post/3lxyfybn55m2o" 129 + "cid": "bafyreigxivgp5rjgohv7y6z3r4wdjw6qbu56ozartttuvtydoth45app7e", 130 + "uri": "at://did:plc:2j2ounbiyi3ftihronlw5qhj/app.bsky.feed.post/3m2k26c2l4u2g" 128 131 }, 129 132 "$type": "place.stream.livestream", 133 + "agent": "@streamplace/components/0.7.35 (web, Firefox)", 130 134 "thumb": { 131 135 "ref": { 132 - "$link": "bafkreiauoc74hcintbaua7tvp233qbfl4iymiyocc5aclhyohkz3bdinty" 136 + "$link": "bafkreihzmf7rywllxelclvnweuefl2bpqkkazznflnhdh24aexnqzh4xum" 133 137 }, 134 - "size": 9231, 138 + "size": 60106, 135 139 "$type": "blob", 136 140 "mimeType": "image/jpeg" 137 141 }, 138 - "title": "🦎🦎", 139 - "createdAt": "2025-10-06T16:25:06.950Z" 142 + "title": "Test", 143 + "createdAt": "2025-10-06T16:35:03.719Z", 144 + "canonicalUrl": "https://headphones-glad-thunder-guide.trycloudflare.com/makeworld.space" 140 145 } 141 146 } 142 147 ], 143 148 "signature_info": { 144 149 "issuer": "Streamplace", 145 - "common_name": "did:key:zQ3shfmFgwDstMiGaAkS4HhMJ7p3pTVhyLTHz9ABbhd4v4KJn", 146 - "cert_serial_number": "54472225560857906834076190516168844896" 150 + "common_name": "did:key:zQ3shiYS17LRhT7x6mfd6HfsHHzz1aD9DpGJUP3aT5f2ghdAy", 151 + "cert_serial_number": "34409281865771434870888029768053492928" 147 152 }, 148 - "label": "urn:c2pa:b23b55a7-bd34-4138-99d8-ce565fab3934" 153 + "label": "urn:c2pa:66130743-a4cd-4f73-a4c7-201079ef127a" 149 154 } 150 155 } 151 156 } ··· 171 176 segments, such as content warnings. However, not everything Streamplace does 172 177 fits neatly into C2PA-compliant metadata, so the primary source of truth for 173 178 metadata on a Streamplace segment remains the `place.stream` assertions. 179 + 180 + You can see existing metadata standards being used in the `cawg.metadata` 181 + assertion, which is an assertion defined outside of the C2PA spec for extra 182 + metadata, by the [Creator Assertions Working Group](https://cawg.io/). 174 183 175 184 ## Code paths 176 185
+14 -14
js/docs/src/content/docs/video-metadata/metadata-record.md
··· 11 11 This record is created by users through the Streamplace frontend and contains 12 12 three main components: 13 13 14 - 1. **Content Warnings** (`place.stream.metadata.contentWarnings`): Users can 15 - select content warnings to indicate to node operators and viewers what types 16 - of warnings have been disclosed. The system supports ten predefined warning 17 - categories including _violence_, _nudity_, _flashing lights_, _language_, 18 - _drug use_, _death_, _sexuality_, _suffering_, _fantasy violence_, and 19 - _personally identifiable information (PII)_. These categories are based on 20 - the 14 + 1. **Content Warnings** (`place.stream.metadata.configuration.contentWarnings`): 15 + Users can select content warnings to indicate to node operators and viewers 16 + what types of warnings have been disclosed. The system supports ten 17 + predefined warning categories including _violence_, _nudity_, _flashing 18 + lights_, _language_, _drug use_, _death_, _sexuality_, _suffering_, _fantasy 19 + violence_, and _personally identifiable information (PII)_. These categories 20 + are based on the 21 21 [IPTC controlled vocabulary for content warnings](https://cv.iptc.org/newscodes/contentwarning/). 22 22 Each warning provides descriptions to help creators properly categorize their 23 23 content. Streamplace node operators may also configure their nodes to exclude 24 24 certain types of content. 25 - 2. **Content Rights** (`place.stream.metadata.contentRights`): This section 26 - captures copyright and attribution information, including the creator’s name, 27 - copyright notice, publication year, license type, and credit line. The system 28 - supports various pre-defined licensing options from several Creative Commons 29 - licenses (CC0, CC-BY, CC-BY-SA, CC-BY-NC, CC-BY-NC-SA, CC-BY-ND, and 30 - CC-BY-NC-ND) to “All Rights Reserved”, as well as the option to input custom 31 - licensing terms. 25 + 2. **Content Rights** (`place.stream.metadata.configuration.contentRights`): 26 + This section captures copyright and attribution information, including the 27 + creator’s name, copyright notice, publication year, license type, and credit 28 + line. The system supports various pre-defined licensing options from several 29 + Creative Commons licenses (CC0, CC-BY, CC-BY-SA, CC-BY-NC, CC-BY-NC-SA, 30 + CC-BY-ND, and CC-BY-NC-ND) to “All Rights Reserved”, as well as the option to 31 + input custom licensing terms. 32 32 3. **Distribution Policy** (`place.stream.metadata.distributionPolicy`): This 33 33 section currently allows creators to specify a `deleteAfter` property, which 34 34 is meant to indicate the time after which the user no longer wants the stream
-3
pkg/constants/constants.go
··· 15 15 const DID_KEY_PREFIX = "did:key" //nolint:all 16 16 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 17 17 18 - // Streamplace metadata constant 19 - const StreamplaceMetadata = "place.stream.metadata" //nolint:all 20 - 21 18 // Streamplace metadata license values 22 19 const ( 23 20 LicenseCC0_1_0 = "place.stream.metadata.contentRights#cc0_1__0"
+16 -26
pkg/media/manifest_builder.go
··· 48 48 func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName string, start int64) ([]byte, error) { 49 49 log.Debug(ctx, "🔍 BuildManifest ENTRY", "streamer", streamerName, "start", start) 50 50 // Start with base manifest 51 + startTime := aqtime.FromMillis(start).String() 51 52 mani := obj{ 52 - "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)), 53 + "title": fmt.Sprintf("Livestream Segment at %s", startTime), 53 54 "assertions": []obj{ 55 + // Required by spec, just basic info 54 56 { 55 57 "label": "c2pa.actions", 56 58 "data": obj{ 57 59 "actions": []obj{ 58 - {"action": "c2pa.created"}, 59 - {"action": "c2pa.published"}, 60 + { 61 + "action": "c2pa.created", 62 + "when": startTime, 63 + }, 64 + { 65 + "action": "c2pa.published", 66 + "when": startTime, 67 + }, 60 68 }, 61 69 }, 62 70 }, 71 + // Content metadata, with extra custom fields added later 63 72 { 64 - "label": constants.StreamplaceMetadata, 73 + "label": "cawg.metadata", 65 74 "data": obj{ 66 75 "@context": obj{ 67 76 "dc": "http://purl.org/dc/elements/1.1/", ··· 70 79 "xmpRights": "http://ns.adobe.com/xap/1.0/rights/", 71 80 }, 72 81 "dc:creator": streamerName, 73 - // TODO: Add the title of the livestream. This should come from the livestream record. 74 - "dc:title": []string{"livestream"}, 75 - "dc:date": []string{aqtime.FromMillis(start).String()}, 82 + "dc:title": "livestream", 83 + "dc:date": startTime, 76 84 }, 77 85 }, 78 86 }, ··· 134 142 } 135 143 136 144 // Update the manifest title with the retrieved livestream title 137 - mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = []string{livestreamTitle} 145 + mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = livestreamTitle 138 146 139 147 // Convert manifest to JSON bytes for use with Rust c2pa library 140 148 manifestBs, err := json.Marshal(mani) ··· 228 236 // Unknown warnings remain unchanged 229 237 } 230 238 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:ContentWarning"] = metadata.ContentWarnings.Warnings 231 - } 232 - 233 - if metadata.DistributionPolicy != nil { 234 - // Convert the distribution policy duration to an absolute expiry timestamp 235 - // deleteAfter is in seconds, startTimeMillis is in milliseconds 236 - if metadata.DistributionPolicy.DeleteAfter != nil { 237 - // Calculate expiry: start time (seconds) + duration (seconds) = expiry timestamp (seconds) 238 - startTimeSeconds := startTimeMillis / 1000 239 - expiresAtSeconds := startTimeSeconds + *metadata.DistributionPolicy.DeleteAfter 240 - 241 - // Convert to ISO 8601 datetime string for C2PA manifest 242 - // Note: In the manifest, we store this in "deleteAfter" field but with timestamp value instead of duration 243 - deleteAfterTimestamp := aqtime.FromMillis(expiresAtSeconds * 1000).String() 244 - 245 - mani["assertions"].([]obj)[1]["data"].(obj)["distributionPolicy"] = obj{ 246 - "deleteAfter": deleteAfterTimestamp, 247 - } 248 - } 249 239 } 250 240 251 241 return mani
+14 -30
pkg/media/media.go
··· 9 9 "fmt" 10 10 "io" 11 11 "sync" 12 - "time" 13 12 14 13 "github.com/google/uuid" 15 14 "github.com/pion/interceptor" ··· 37 36 const CertFile = "cert.pem" 38 37 const SegmentsDir = "segments" 39 38 40 - var StreamplaceMetadata = "place.stream.metadata" 39 + const StreamplaceMetadata = "cawg.metadata" 41 40 42 41 type MediaManager struct { 43 42 cli *config.CLI ··· 209 208 ass = &a 210 209 break 211 210 } 211 + if a.Label == "place.stream.metadata" { 212 + // backwards compatibility for old manifests 213 + ass = &a 214 + break 215 + } 212 216 } 213 217 if ass == nil { 214 218 return nil, ErrMissingMetadata ··· 372 376 373 377 // extractDistributionPolicy extracts distribution policy from the C2PA manifest 374 378 func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *model.DistributionPolicy { 375 - ass := findAssertion(mani, StreamplaceMetadata) 376 - if ass == nil { 377 - return nil 378 - } 379 - 380 - data, ok := ass.Data.(map[string]interface{}) 381 - if !ok { 382 - return nil 383 - } 384 - 385 - policy, ok := data["distributionPolicy"] 386 - if !ok { 387 - return nil 388 - } 389 - 390 - policyMap, ok := policy.(map[string]interface{}) 391 - if !ok { 379 + metadataConfig := extractMetadataConfiguration(mani) 380 + if metadataConfig == nil { 392 381 return nil 393 382 } 394 383 395 - expiry, ok := policyMap["deleteAfter"] 396 - if !ok { 384 + if metadataConfig.DistributionPolicy == nil { 397 385 return nil 398 386 } 399 387 400 - // deleteAfter now contains a timestamp string (RFC3339/ISO 8601 format) 401 - expiryStr, ok := expiry.(string) 402 - if !ok { 388 + if metadataConfig.DistributionPolicy.DeleteAfter == nil { 403 389 return nil 404 390 } 405 391 406 - expiryTime, err := time.Parse(time.RFC3339, expiryStr) 407 - if err != nil { 408 - return nil 409 - } 392 + // deleteAfter contains an offset in seconds from creation time 393 + deleteAfterSeconds := *metadataConfig.DistributionPolicy.DeleteAfter 410 394 411 395 return &model.DistributionPolicy{ 412 - ExpiresAt: &expiryTime, 396 + DeleteAfterSeconds: &deleteAfterSeconds, 413 397 } 414 398 } 415 399 416 - // extractDistributionPolicy extracts the place.stream.metadata.configuration from the C2PA manifest 400 + // extractMetadataConfiguration extracts the place.stream.metadata.configuration from the C2PA manifest 417 401 func extractMetadataConfiguration(mani *c2patypes.Manifest) *streamplace.MetadataConfiguration { 418 402 ass := findAssertion(mani, "place.stream.metadata.configuration") 419 403 if ass == nil {
+13 -6
pkg/media/media_signer.go
··· 112 112 // Fallback to basic manifest without metadata 113 113 ctx, span = otel.Tracer("signer").Start(ctx, "SignMP4_BasicManifest") 114 114 title := "livestream" 115 + startTime := aqtime.FromMillis(start).String() 115 116 mani := obj{ 116 - "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)), 117 + "title": fmt.Sprintf("Livestream Segment at %s", startTime), 117 118 "assertions": []obj{ 118 119 { 119 120 "label": "c2pa.actions", 120 121 "data": obj{ 121 122 "actions": []obj{ 122 - {"action": "c2pa.created"}, 123 - {"action": "c2pa.published"}, 123 + { 124 + "action": "c2pa.created", 125 + "when": startTime, 126 + }, 127 + { 128 + "action": "c2pa.published", 129 + "when": startTime, 130 + }, 124 131 }, 125 132 }, 126 133 }, 127 134 { 128 - "label": StreamplaceMetadata, 135 + "label": "cawg.metadata", 129 136 "data": obj{ 130 137 "@context": obj{ 131 138 "dc": "http://purl.org/dc/elements/1.1/", 132 139 }, 133 140 "dc:creator": ms.StreamerName, 134 - "dc:title": []string{title}, 135 - "dc:date": []string{aqtime.FromMillis(start).String()}, 141 + "dc:title": title, 142 + "dc:date": startTime, 136 143 }, 137 144 }, 138 145 },
+6 -2
pkg/media/media_test.go
··· 12 12 "stream.place/streamplace/pkg/config" 13 13 ct "stream.place/streamplace/pkg/config/configtesting" 14 14 "stream.place/streamplace/pkg/model" 15 + "stream.place/streamplace/pkg/statedb" 15 16 ) 16 17 17 18 func getFixture(name string) string { ··· 30 31 } 31 32 cli := ct.CLI(t, &config.CLI{ 32 33 TAURL: "http://timestamp.digicert.com", 33 - AllowedStreams: []string{"did:key:zQ3shhoPCrDZWE8CryCEHYCrb1x8mCkr2byTkF5EGJT7dgazC"}, 34 + AllowedStreams: []string{"did:plc:2j2ounbiyi3ftihronlw5qhj"}, 35 + DBURL: ":memory:", 34 36 }) 37 + statedb, err := statedb.MakeDB(context.Background(), cli, nil, mod) 38 + require.NoError(t, err) 35 39 atsync := &atproto.ATProtoSynchronizer{ 36 40 CLI: cli, 37 41 Model: mod, 38 - StatefulDB: nil, // Test doesn't need StatefulDB for now 42 + StatefulDB: statedb, 39 43 Bus: bus.NewBus(), 40 44 } 41 45 mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync)
+1
pkg/media/segment_roundtrip_test.go
··· 57 57 t.Run(testCase.name, func(t *testing.T) { 58 58 withNoGSTLeaks(t, func() { 59 59 tempDir, err := os.MkdirTemp("", "ingredient_test") 60 + t.Logf("tempDir: %s", tempDir) 60 61 require.NoError(t, err) 61 62 getTestVids := func() []io.ReadSeeker { 62 63 testVids := []io.ReadSeeker{}
+3
pkg/media/segment_split.go
··· 126 126 SegmentMetadata: metadata, 127 127 }) 128 128 } 129 + if len(manifestList) == 0 { 130 + return fmt.Errorf("no manifests found") 131 + } 129 132 sort.Slice(manifestList, func(i, j int) bool { 130 133 m1 := manifestList[i] 131 134 m2 := manifestList[j]
+8 -6
pkg/media/validate.go
··· 113 113 return err 114 114 } 115 115 var deleteAfter *time.Time 116 - if meta.DistributionPolicy != nil && meta.DistributionPolicy.ExpiresAt != nil { 117 - deleteAfter = meta.DistributionPolicy.ExpiresAt 116 + if meta.DistributionPolicy != nil && meta.DistributionPolicy.DeleteAfterSeconds != nil { 117 + expiryTime := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 118 + deleteAfter = &expiryTime 118 119 } 119 120 seg := &model.Segment{ 120 121 ID: *label, ··· 173 174 174 175 // Check distribution policy (if enabled) 175 176 if mm.cli.ContentFilters.DistributionPolicy.Enabled && meta.DistributionPolicy != nil { 176 - if meta.DistributionPolicy.ExpiresAt != nil { 177 - if time.Now().After(*meta.DistributionPolicy.ExpiresAt) { 178 - reason := fmt.Sprintf("distribution policy expired: segment expires at %s", meta.DistributionPolicy.ExpiresAt) 177 + if meta.DistributionPolicy.DeleteAfterSeconds != nil { 178 + expiresAt := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 179 + if time.Now().After(expiresAt) { 180 + reason := fmt.Sprintf("distribution policy expired: segment expires at %s", expiresAt) 179 181 log.Log(ctx, "content filtered", 180 182 "reason", reason, 181 183 "filter_type", "distribution_policy", 182 184 "creator", meta.Creator, 183 185 "start_time", meta.StartTime, 184 - "expires_at", *meta.DistributionPolicy.ExpiresAt) 186 + "expires_at", expiresAt) 185 187 return fmt.Errorf("content filtered: %s", reason) 186 188 } 187 189 }
+3 -7
pkg/model/segment.go
··· 85 85 86 86 // DistributionPolicy represents distribution policy information 87 87 type DistributionPolicy struct { 88 - ExpiresAt *time.Time `json:"expiresAt,omitempty"` 88 + DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 89 } 90 90 91 91 // Scan scan value into DistributionPolicy, implements sql.Scanner interface ··· 185 185 } 186 186 187 187 var distributionPolicy *streamplace.MetadataDistributionPolicy 188 - if s.DistributionPolicy != nil && s.DistributionPolicy.ExpiresAt != nil { 189 - // Convert the absolute timestamp back to a duration (in seconds) from segment start 190 - startTimeUnix := s.StartTime.Unix() 191 - expiresAtUnix := s.DistributionPolicy.ExpiresAt.Unix() 192 - deleteAfterSecs := expiresAtUnix - startTimeUnix 188 + if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 193 189 distributionPolicy = &streamplace.MetadataDistributionPolicy{ 194 - DeleteAfter: &deleteAfterSecs, 190 + DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 195 191 } 196 192 } 197 193