Live video on the AT Protocol
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}