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 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}