Live video on the AT Protocol
79
fork

Configure Feed

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

at natb/offset-scrolltobottom 242 lines 9.3 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 startTime := aqtime.FromMillis(start).String() 52 mani := obj{ 53 "title": fmt.Sprintf("Livestream Segment at %s", startTime), 54 "assertions": []obj{ 55 // Required by spec, just basic info 56 { 57 "label": "c2pa.actions", 58 "data": obj{ 59 "actions": []obj{ 60 { 61 "action": "c2pa.created", 62 "when": startTime, 63 }, 64 { 65 "action": "c2pa.published", 66 "when": startTime, 67 }, 68 }, 69 }, 70 }, 71 // Content metadata, with extra custom fields added later 72 { 73 "label": "cawg.metadata", 74 "data": obj{ 75 "@context": obj{ 76 "dc": "http://purl.org/dc/elements/1.1/", 77 "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", 78 "photoshop": "http://ns.adobe.com/photoshop/1.0/", 79 "xmpRights": "http://ns.adobe.com/xap/1.0/rights/", 80 }, 81 "dc:creator": streamerName, 82 "dc:title": "livestream", 83 "dc:date": startTime, 84 }, 85 }, 86 }, 87 } 88 89 // Add database metadata if available 90 if mb.model != nil { 91 metadata, err := mb.model.GetMetadataConfiguration(ctx, streamerName) 92 if err != nil { 93 log.Warn(ctx, "ManifestBuilder: failed to retrieve metadata", "error", err, "did", streamerName) 94 return nil, fmt.Errorf("failed to retrieve metadata: %w", err) 95 } else if metadata != nil { 96 log.Debug(ctx, "ManifestBuilder: found metadata configuration", "did", streamerName, "metadata", metadata) 97 streamplaceMetadata, err := metadata.ToStreamplaceMetadataConfiguration() 98 if err != nil { 99 log.Warn(ctx, "ManifestBuilder: failed to convert metadata, using defaults", "error", err, "did", streamerName) 100 } else { 101 log.Debug(ctx, "ManifestBuilder: enhancing manifest with metadata", "did", streamerName, "contentWarnings", streamplaceMetadata.ContentWarnings, "contentRights", streamplaceMetadata.ContentRights) 102 mani = mb.enhanceManifestWithMetadata(mani, streamplaceMetadata, start) 103 metadataObj, err := toObj(streamplaceMetadata) 104 if err != nil { 105 return nil, fmt.Errorf("failed to marshal metadata: %w", err) 106 } 107 mani["assertions"] = append(mani["assertions"].([]obj), obj{ 108 "label": "place.stream.metadata.configuration", 109 "data": metadataObj, 110 }) 111 } 112 } else { 113 log.Warn(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) 114 } 115 } 116 117 // Add livestream title if available 118 livestreamTitle := "livestream" // default fallback 119 if mb.model != nil { 120 livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) 121 if err != nil { 122 log.Warn(ctx, "ManifestBuilder: failed to retrieve livestream, using default title", "error", err, "did", streamerName) 123 } else if livestream != nil { 124 // Extract title from livestream record 125 livestreamRecord, err := livestream.ToLivestreamView() 126 if err != nil { 127 log.Warn(ctx, "ManifestBuilder: failed to convert livestream to view, using default title", "error", err, "did", streamerName) 128 } else { 129 if ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream); ok { 130 livestreamTitle = ls.Title 131 livestreamObj, err := toObj(ls) 132 if err != nil { 133 return nil, fmt.Errorf("failed to marshal livestream: %w", err) 134 } 135 mani["assertions"] = append(mani["assertions"].([]obj), obj{ 136 "label": "place.stream.livestream", 137 "data": livestreamObj, 138 }) 139 } 140 } 141 } 142 } 143 144 // Update the manifest title with the retrieved livestream title 145 mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = livestreamTitle 146 147 // Convert manifest to JSON bytes for use with Rust c2pa library 148 manifestBs, err := json.Marshal(mani) 149 if err != nil { 150 return nil, fmt.Errorf("failed to marshal manifest: %w", err) 151 } 152 153 return manifestBs, nil 154} 155 156// getLicenseCodeMap returns a map of internal license codes to their corresponding URLs 157func getLicenseCodeMap() map[string]string { 158 return map[string]string{ 159 constants.LicenseCC0_1_0: constants.LicenseURLCC0_1_0, 160 constants.LicenseCCBy_4_0: constants.LicenseURLCCBy_4_0, 161 constants.LicenseCCBySA_4_0: constants.LicenseURLCCBySA_4_0, 162 constants.LicenseCCByNC_4_0: constants.LicenseURLCCByNC_4_0, 163 constants.LicenseCCByNCSA_4_0: constants.LicenseURLCCByNCSA_4_0, 164 constants.LicenseCCByND_4_0: constants.LicenseURLCCByND_4_0, 165 constants.LicenseCCByNCND_4_0: constants.LicenseURLCCByNCND_4_0, 166 } 167} 168 169// getWarningCodeMap returns a map of internal warning codes to their corresponding C2PA codes 170func getWarningCodeMap() map[string]string { 171 return map[string]string{ 172 constants.WarningDeath: constants.WarningC2PADeath, 173 constants.WarningDrugUse: constants.WarningC2PADrugUse, 174 constants.WarningFantasyViolence: constants.WarningC2PAFantasyViolence, 175 constants.WarningFlashingLights: constants.WarningC2PAFlashingLights, 176 constants.WarningLanguage: constants.WarningC2PALanguage, 177 constants.WarningNudity: constants.WarningC2PANudity, 178 constants.WarningPII: constants.WarningC2PAPII, 179 constants.WarningSexuality: constants.WarningC2PASexuality, 180 constants.WarningSuffering: constants.WarningC2PASuffering, 181 constants.WarningViolence: constants.WarningC2PAViolence, 182 } 183} 184 185func (mb *ManifestBuilder) enhanceManifestWithMetadata(mani obj, metadata *streamplace.MetadataConfiguration, startTimeMillis int64) obj { 186 if metadata.ContentRights != nil { 187 // TODO: We are currently validating the creator in the ValidateMP4 function to be the streamer DID 188 // if metadata.ContentRights.Creator != nil { 189 // mani["assertions"].([]obj)[1]["data"].(obj)["dc:creator"] = *metadata.ContentRights.Creator 190 // } 191 192 // Copyright Notice 193 if metadata.ContentRights.CopyrightNotice != nil { 194 mani["assertions"].([]obj)[1]["data"].(obj)["dc:rights"] = *metadata.ContentRights.CopyrightNotice 195 } 196 197 // Copyright Year 198 if metadata.ContentRights.CopyrightYear != nil { 199 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:CopyrightYear"] = *metadata.ContentRights.CopyrightYear 200 } 201 202 // Credit Line 203 if metadata.ContentRights.CreditLine != nil { 204 mani["assertions"].([]obj)[1]["data"].(obj)["photoshop:Credit"] = *metadata.ContentRights.CreditLine 205 } 206 207 // Build the license field 208 if metadata.ContentRights.License != nil { 209 // Map internal license codes to known licenses 210 licenseCodeMap := getLicenseCodeMap() 211 if mappedCode, exists := licenseCodeMap[*metadata.ContentRights.License]; exists { 212 // it's a known linked license, so we can use the mapped code 213 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:LinkedEncRightsExpr"] = mappedCode 214 } else { 215 // This is either an unknown or an unlinked license, so we need to put it in the UsageTerms field 216 // which allows for licensing terms expressed in free text 217 if *metadata.ContentRights.License == constants.LicenseAllRightsReserved { 218 // if all rights reserved, we can put the string "All rights reserved" in the UsageTerms field 219 mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = "All rights reserved" 220 } else { 221 // it's an unknown license, so we need to put it directly in the UsageTerms field 222 mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = *metadata.ContentRights.License 223 } 224 } 225 } 226 } 227 228 if metadata.ContentWarnings != nil && len(metadata.ContentWarnings.Warnings) > 0 { 229 // Map internal warning codes to C2PA warning codes 230 warningCodeMap := getWarningCodeMap() 231 232 for i, warning := range metadata.ContentWarnings.Warnings { 233 if mappedCode, exists := warningCodeMap[warning]; exists { 234 metadata.ContentWarnings.Warnings[i] = mappedCode 235 } 236 // Unknown warnings remain unchanged 237 } 238 mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:ContentWarning"] = metadata.ContentWarnings.Warnings 239 } 240 241 return mani 242}