Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at eli/rust-experimentation 252 lines 10 kB view raw
1package media 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 8 "stream.place/streamplace/pkg/aqtime" 9 "stream.place/streamplace/pkg/constants" 10 "stream.place/streamplace/pkg/log" 11 "stream.place/streamplace/pkg/model" 12 "stream.place/streamplace/pkg/streamplace" 13) 14 15// ManifestBuilder is responsible for creating C2PA (Content Credentials) manifests 16// for livestream segments. 17// The builder creates manifests that include: 18// - Basic livestream information (title, creator, date) 19// - Content rights and copyright information 20// - Content warnings for sensitive material 21// - Distribution policies 22// - C2PA action history (created, published) 23// The manifest is meant to align closely with the IPTC Video Metadata Recommendations. 24// See https://iptc.org/std/videometadatahub/recommendation/IPTC-VideoMetadataHub-props-Rec_1.6.html 25type ManifestBuilder struct { 26 model model.Model 27} 28 29func NewManifestBuilder(model model.Model) *ManifestBuilder { 30 return &ManifestBuilder{ 31 model: model, 32 } 33} 34 35func toObj(record any) (obj, error) { 36 jsonBs, err := json.Marshal(record) 37 if err != nil { 38 return nil, fmt.Errorf("failed to marshal record: %w", err) 39 } 40 var o obj 41 err = json.Unmarshal(jsonBs, &o) 42 if err != nil { 43 return nil, fmt.Errorf("failed to unmarshal record: %w", err) 44 } 45 return o, nil 46} 47 48func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName string, start int64) ([]byte, error) { 49 log.Debug(ctx, "🔍 BuildManifest ENTRY", "streamer", streamerName, "start", start) 50 // Start with base manifest 51 mani := obj{ 52 "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)), 53 "assertions": []obj{ 54 { 55 "label": "c2pa.actions", 56 "data": obj{ 57 "actions": []obj{ 58 {"action": "c2pa.created"}, 59 {"action": "c2pa.published"}, 60 }, 61 }, 62 }, 63 { 64 "label": constants.StreamplaceMetadata, 65 "data": obj{ 66 "@context": obj{ 67 "dc": "http://purl.org/dc/elements/1.1/", 68 "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", 69 "photoshop": "http://ns.adobe.com/photoshop/1.0/", 70 "xmpRights": "http://ns.adobe.com/xap/1.0/rights/", 71 }, 72 "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()}, 76 }, 77 }, 78 }, 79 } 80 81 // Add database metadata if available 82 if mb.model != nil { 83 metadata, err := mb.model.GetMetadataConfiguration(ctx, streamerName) 84 if err != nil { 85 log.Warn(ctx, "ManifestBuilder: failed to retrieve metadata", "error", err, "did", streamerName) 86 return nil, fmt.Errorf("failed to retrieve metadata: %w", err) 87 } else if metadata != nil { 88 log.Debug(ctx, "ManifestBuilder: found metadata configuration", "did", streamerName, "metadata", metadata) 89 streamplaceMetadata, err := metadata.ToStreamplaceMetadataConfiguration() 90 if err != nil { 91 log.Warn(ctx, "ManifestBuilder: failed to convert metadata, using defaults", "error", err, "did", streamerName) 92 } else { 93 log.Debug(ctx, "ManifestBuilder: enhancing manifest with metadata", "did", streamerName, "contentWarnings", streamplaceMetadata.ContentWarnings, "contentRights", streamplaceMetadata.ContentRights) 94 mani = mb.enhanceManifestWithMetadata(mani, streamplaceMetadata, start) 95 metadataObj, err := toObj(streamplaceMetadata) 96 if err != nil { 97 return nil, fmt.Errorf("failed to marshal metadata: %w", err) 98 } 99 mani["assertions"] = append(mani["assertions"].([]obj), obj{ 100 "label": "place.stream.metadata.configuration", 101 "data": metadataObj, 102 }) 103 } 104 } else { 105 log.Warn(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) 106 } 107 } 108 109 // Add livestream title if available 110 livestreamTitle := "livestream" // default fallback 111 if mb.model != nil { 112 livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) 113 if err != nil { 114 log.Warn(ctx, "ManifestBuilder: failed to retrieve livestream, using default title", "error", err, "did", streamerName) 115 } else if livestream != nil { 116 // Extract title from livestream record 117 livestreamRecord, err := livestream.ToLivestreamView() 118 if err != nil { 119 log.Warn(ctx, "ManifestBuilder: failed to convert livestream to view, using default title", "error", err, "did", streamerName) 120 } else { 121 if ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream); ok { 122 livestreamTitle = ls.Title 123 livestreamObj, err := toObj(ls) 124 if err != nil { 125 return nil, fmt.Errorf("failed to marshal livestream: %w", err) 126 } 127 mani["assertions"] = append(mani["assertions"].([]obj), obj{ 128 "label": "place.stream.livestream", 129 "data": livestreamObj, 130 }) 131 } 132 } 133 } 134 } 135 136 // Update the manifest title with the retrieved livestream title 137 mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = []string{livestreamTitle} 138 139 // Convert manifest to JSON bytes for use with Rust c2pa library 140 manifestBs, err := json.Marshal(mani) 141 if err != nil { 142 return nil, fmt.Errorf("failed to marshal manifest: %w", err) 143 } 144 145 return manifestBs, nil 146} 147 148// getLicenseCodeMap returns a map of internal license codes to their corresponding URLs 149func getLicenseCodeMap() map[string]string { 150 return map[string]string{ 151 constants.LicenseCC0_1_0: constants.LicenseURLCC0_1_0, 152 constants.LicenseCCBy_4_0: constants.LicenseURLCCBy_4_0, 153 constants.LicenseCCBySA_4_0: constants.LicenseURLCCBySA_4_0, 154 constants.LicenseCCByNC_4_0: constants.LicenseURLCCByNC_4_0, 155 constants.LicenseCCByNCSA_4_0: constants.LicenseURLCCByNCSA_4_0, 156 constants.LicenseCCByND_4_0: constants.LicenseURLCCByND_4_0, 157 constants.LicenseCCByNCND_4_0: constants.LicenseURLCCByNCND_4_0, 158 } 159} 160 161// getWarningCodeMap returns a map of internal warning codes to their corresponding C2PA codes 162func getWarningCodeMap() map[string]string { 163 return map[string]string{ 164 constants.WarningDeath: constants.WarningC2PADeath, 165 constants.WarningDrugUse: constants.WarningC2PADrugUse, 166 constants.WarningFantasyViolence: constants.WarningC2PAFantasyViolence, 167 constants.WarningFlashingLights: constants.WarningC2PAFlashingLights, 168 constants.WarningLanguage: constants.WarningC2PALanguage, 169 constants.WarningNudity: constants.WarningC2PANudity, 170 constants.WarningPII: constants.WarningC2PAPII, 171 constants.WarningSexuality: constants.WarningC2PASexuality, 172 constants.WarningSuffering: constants.WarningC2PASuffering, 173 constants.WarningViolence: constants.WarningC2PAViolence, 174 } 175} 176 177func (mb *ManifestBuilder) enhanceManifestWithMetadata(mani obj, metadata *streamplace.MetadataConfiguration, startTimeMillis int64) obj { 178 if metadata.ContentRights != nil { 179 // TODO: We are currently validating the creator in the ValidateMP4 function to be the streamer DID 180 // if metadata.ContentRights.Creator != nil { 181 // mani["assertions"].([]obj)[1]["data"].(obj)["dc:creator"] = *metadata.ContentRights.Creator 182 // } 183 184 // Copyright Notice 185 if metadata.ContentRights.CopyrightNotice != nil { 186 mani["assertions"].([]obj)[1]["data"].(obj)["dc:rights"] = *metadata.ContentRights.CopyrightNotice 187 } 188 189 // Copyright Year 190 if metadata.ContentRights.CopyrightYear != nil { 191 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:CopyrightYear"] = *metadata.ContentRights.CopyrightYear 192 } 193 194 // Credit Line 195 if metadata.ContentRights.CreditLine != nil { 196 mani["assertions"].([]obj)[1]["data"].(obj)["photoshop:Credit"] = *metadata.ContentRights.CreditLine 197 } 198 199 // Build the license field 200 if metadata.ContentRights.License != nil { 201 // Map internal license codes to known licenses 202 licenseCodeMap := getLicenseCodeMap() 203 if mappedCode, exists := licenseCodeMap[*metadata.ContentRights.License]; exists { 204 // it's a known linked license, so we can use the mapped code 205 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:LinkedEncRightsExpr"] = mappedCode 206 } else { 207 // This is either an unknown or an unlinked license, so we need to put it in the UsageTerms field 208 // which allows for licensing terms expressed in free text 209 if *metadata.ContentRights.License == constants.LicenseAllRightsReserved { 210 // if all rights reserved, we can put the string "All rights reserved" in the UsageTerms field 211 mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = "All rights reserved" 212 } else { 213 // it's an unknown license, so we need to put it directly in the UsageTerms field 214 mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = *metadata.ContentRights.License 215 } 216 } 217 } 218 } 219 220 if metadata.ContentWarnings != nil && len(metadata.ContentWarnings.Warnings) > 0 { 221 // Map internal warning codes to C2PA warning codes 222 warningCodeMap := getWarningCodeMap() 223 224 for i, warning := range metadata.ContentWarnings.Warnings { 225 if mappedCode, exists := warningCodeMap[warning]; exists { 226 metadata.ContentWarnings.Warnings[i] = mappedCode 227 } 228 // Unknown warnings remain unchanged 229 } 230 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 } 250 251 return mani 252}