@eluvio/elv-client-js 4.2.14 → 4.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eluvio/elv-client-js",
3
- "version": "4.2.14",
3
+ "version": "4.2.15",
4
4
  "description": "Javascript client for the Eluvio Content Fabric",
5
5
  "main": "src/index.js",
6
6
  "author": "Kevin Talmadge",
@@ -1505,6 +1505,7 @@ exports.PlayoutOptions = async function({
1505
1505
  playoutMethods: {
1506
1506
  ...((playoutMap[protocol] || {}).playoutMethods || {}),
1507
1507
  [drm || "clear"]: {
1508
+ properties: option.properties || {},
1508
1509
  playoutUrl:
1509
1510
  signedLink ?
1510
1511
  await this.LinkUrl({
@@ -202,4 +202,4 @@ if(require.main === module) {
202
202
  Utility.cmdLineInvoke(ChannelCreate);
203
203
  } else {
204
204
  module.exports = ChannelCreate;
205
- }
205
+ }
@@ -0,0 +1,517 @@
1
+ /**
2
+ * -----------------------------------------------------------------------------
3
+ * CompositionCreate.js — Usage Example
4
+ * -----------------------------------------------------------------------------
5
+ *
6
+ * This script creates (or updates) a CHANNEL / COMPOSITION offering on a
7
+ * base object by stitching together multiple mezzanine objects that all share
8
+ * identical playout parameters and can be clipped at the start and end time
9
+ * provided in fromat HH_MM_SS_MS
10
+ *
11
+ * Each item is specified as:
12
+ * - OBJECT_ID (default offering)
13
+ * - OBJECT_ID:OFFERING_KEY
14
+ * - OBJECT_ID:OFFERING_KEY:START_TC:END_TC
15
+ * - OBJECT_ID::START_TC:END_TC (default offering)
16
+ *
17
+ * Items are passed as a single comma-separated string.
18
+ *
19
+ * -----------------------------------------------------------------------------
20
+ * BASIC USAGE
21
+ * -----------------------------------------------------------------------------
22
+ *
23
+ * node CompositionCreate.js \
24
+ * --library-id ilib_xxxxxxxxxxxxxxxxx \
25
+ * --name "Full Game DASH" \
26
+ * --base-object-id iq__xxxxxxxxxxxxxxxx \
27
+ * --items iq__100:default_dash:1_2_3_4:2_3_4_5,iq__200:default_dash,iq__300:default_dash
28
+ * --base-range 1_2_3_4:2_3_4_5 => (hh_mm_ss_ms)
29
+ * --base-offering-key default_dash
30
+ *
31
+ * Without offering:
32
+ * node CompositionCreate.js \
33
+ * --library-id ilib_xxxxxxxxxxxxxxxxx \
34
+ * --name "Full Game DASH" \
35
+ * --base-object-id iq__xxxxxxxxxxxxxxxx \
36
+ * --items iq__100,iq__200,iq__300
37
+ * -----------------------------------------------------------------------------
38
+ * ARGUMENTS
39
+ * -----------------------------------------------------------------------------
40
+ *
41
+ * --library-id
42
+ * The content library where all objects exist.
43
+ *
44
+ * --name
45
+ * Display name for the channel offering.
46
+ * This value is sanitized and used as the offering key.
47
+ *
48
+ * --base-object-id
49
+ * The object ID where the channel metadata will be written.
50
+ * This object will receive /metadata/channel/offerings/<key>.
51
+ *
52
+ * --items
53
+ * Comma-separated list of OBJECT_ID:OFFERING_KEY pairs.
54
+ *
55
+ * Example:
56
+ * iq__100:default_dash
57
+ * iq__200:default_dash
58
+ * iq__300:default_dash
59
+ *
60
+ * All items MUST:
61
+ * - Have identical playout.streams.representations
62
+ * - Have identical playout.playout_formats
63
+ * - Have matching media_struct stream parameters
64
+ *
65
+ * -----------------------------------------------------------------------------
66
+ * WHAT THIS SCRIPT DOES
67
+ * -----------------------------------------------------------------------------
68
+ *
69
+ * 1. Fetches metadata for each OBJECT_ID.
70
+ * 2. Validates all items are playout-compatible.
71
+ * 3. Creates / merges metadata.channel.offerings on the base object.
72
+ * 4. Adds each item as a sequential channel source.
73
+ * 5. Sets createdAt on first item and updatedAt on last item.
74
+ * 6. Writes updated metadata back to Fabric.
75
+ *
76
+ */
77
+
78
+ const R = require("ramda");
79
+ const Fraction = require("fraction.js");
80
+ const { FrameAccurateVideo } = require("./lib/FrameAccurateVideo");
81
+
82
+ const { ModOpt, NewOpt, StdOpt } = require("./lib/options");
83
+ const Utility = require("./lib/Utility");
84
+
85
+ const ArgLibraryId = require("./lib/concerns/ArgLibraryId");
86
+ const FabricObject = require("./lib/concerns/FabricObject");
87
+
88
+ const STREAM_FIELDS = [
89
+ "aspect_ratio",
90
+ "channel_layout",
91
+ "channels",
92
+ "codec_name",
93
+ "codec_type",
94
+ "height",
95
+ "rate",
96
+ "width"
97
+ ];
98
+
99
+ /**
100
+ * Parse item string
101
+ * Formats:
102
+ * - OBJECT_ID
103
+ * - OBJECT_ID:OFFERING_KEY
104
+ * - OBJECT_ID:OFFERING_KEY:START_TC:END_TC
105
+ * - OBJECT_ID::START_TC:END_TC (default offering)
106
+ */
107
+ const itemParser = (itemStr) => {
108
+ if (typeof itemStr !== "string" || !itemStr.trim()) {
109
+ throw new Error("Item must be a non-empty string");
110
+ }
111
+
112
+ const parts = itemStr.split(":");
113
+
114
+ const objectId = parts[0];
115
+ if(!objectId) {
116
+ throw new Error(`Invalid item format: '${itemStr}'`);
117
+ }
118
+
119
+ const offering = parts[1] || "default";
120
+
121
+ const startTC = parts.length >= 3 ? parts[2] : undefined;
122
+ const endTC = parts.length >= 4? parts[3] : undefined;
123
+
124
+ return {
125
+ objectId,
126
+ offering,
127
+ startTC,
128
+ endTC
129
+ };
130
+ };
131
+
132
+ const mediaStructStreamFields = R.pick(STREAM_FIELDS);
133
+ const msStreamsFieldSubset = R.map(mediaStructStreamFields);
134
+
135
+ // return media_struct stream keys appearing in playout.streams.representations
136
+ const poStreamMsStreamKeys = poStream =>
137
+ R.uniq(R.map(R.prop("media_struct_stream_key"), poStream.representations));
138
+
139
+ // return fields that need to be checked from media_struct.streams
140
+ const msStreamFields = (poStreams, msStreams) => {
141
+ const msStreamKeys = R.uniq(R.values(R.map(poStreamMsStreamKeys, poStreams)));
142
+ const usedMsStreams = R.pick(msStreamKeys, msStreams);
143
+ return R.map(msStreamsFieldSubset, usedMsStreams);
144
+ };
145
+
146
+ // convert HH_MM_SS_MS format to seconds
147
+ const convertTimecodeToSeconds = (timecode) => {
148
+ if (typeof timecode !== "string") {
149
+ throw new Error("Timecode must be a string");
150
+ }
151
+
152
+ const match = timecode.match(
153
+ /^(\d{1,2})_(\d{1,2})_(\d{1,2})(?:_(\d{1,3}))?$/
154
+ );
155
+ if(!match) {
156
+ throw new Error(`Invalid timecode format: ${timecode}`);
157
+ }
158
+
159
+ const [,hh,mm,ss,msRaw = "0"] = match;
160
+ const hours = parseInt(hh, 10);
161
+ const minutes = parseInt(mm, 10);
162
+ const seconds = parseInt(ss, 10);
163
+ const ms = msRaw.padEnd(3, "0"); // normalize to 3 digits
164
+
165
+ return Fraction(hours).mul(3600)
166
+ .add(Fraction(minutes).mul(60))
167
+ .add(Fraction(seconds))
168
+ .add(Fraction(ms).div(1000));
169
+ };
170
+
171
+
172
+ const deriveSliceAndDurationFromVideoStream = ({offering, startTC, endTC}) => {
173
+ const streams = offering?.media_struct?.streams;
174
+ if (!streams) {
175
+ throw new Error("Missing media_struct.streams in offering");
176
+ }
177
+
178
+ const videoStream = Object.values(streams).find(
179
+ s => s.codec_type === "video"
180
+ );
181
+ if (!videoStream) {
182
+ throw new Error("No video stream found in offering");
183
+ }
184
+
185
+ let clipStart = Fraction(0), clipEnd;
186
+ if(startTC) {
187
+ clipStart = convertTimecodeToSeconds(startTC);
188
+ }
189
+ if(endTC) {
190
+ clipEnd = convertTimecodeToSeconds(endTC);
191
+ }
192
+
193
+ const streamDuration = Fraction(videoStream.duration.rat);
194
+ if(!clipEnd) {
195
+ const frameDur = Fraction(videoStream.rate).inverse();
196
+ clipEnd = Fraction(streamDuration.div(frameDur).floor().mul(frameDur));
197
+ }
198
+
199
+ if (clipEnd.compare(clipStart) <= 0) {
200
+ throw new Error("Invalid clip range: end must be after start");
201
+ }
202
+
203
+ if (clipEnd.compare(streamDuration) > 0) {
204
+ throw new Error("Clip end exceeds video duration");
205
+ }
206
+
207
+ const videoHandler = new FrameAccurateVideo({
208
+ frameRateRat: videoStream.rate
209
+ });
210
+
211
+ const clipInFrame = videoHandler.TimeToFrame(clipStart);
212
+ const clipOutFrame = videoHandler.TimeToFrame(clipEnd, true);
213
+
214
+ return {
215
+ slice_start_rat: videoHandler.FrameToRat(clipInFrame),
216
+ slice_end_rat: videoHandler.FrameToRat(clipOutFrame),
217
+ duration_rat: videoHandler.FrameToRat(clipOutFrame - clipInFrame)
218
+ };
219
+ };
220
+
221
+ const withoutDrmContentId = poFormat => {
222
+ const clone = R.clone(poFormat);
223
+ if (clone.drm) clone.drm.content_id = null;
224
+ return clone;
225
+ };
226
+
227
+ const withoutEncryptionSchemes = poStream => {
228
+ const clone = R.clone(poStream);
229
+ clone.encryption_schemes = null;
230
+ return clone;
231
+ };
232
+
233
+ const sanitizeFilename = (name, fallback) => {
234
+ if (!name) return fallback;
235
+ return name
236
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
237
+ .replace(/_+/g, "_")
238
+ .substring(0, 180)
239
+ .toLowerCase();
240
+ };
241
+
242
+ class CompositionCreate extends Utility {
243
+ blueprint() {
244
+ return {
245
+ concerns: [ArgLibraryId, FabricObject],
246
+ options: [
247
+ ModOpt("libraryId", { demand: true }),
248
+ StdOpt("name", { forX: "channel object" }),
249
+ NewOpt("baseObjectId", {
250
+ demand: true,
251
+ descTemplate: "Base object ID to write channel metadata to",
252
+ string: true
253
+ }),
254
+ NewOpt("items", {
255
+ descTemplate:
256
+ "Comma-separated list of OBJECT_ID:OFFERING_KEY:START_TIME:END_TIME, The start and end time are provide in form HH_MM_SS_MS (e.g. iq__100:default_dash:1_20_3_5:1_30_0_0,iq__200:default_dash)",
257
+ string: true
258
+ }),
259
+ NewOpt("force", {
260
+ descTemplate: "Overwrite existing composition if it already exists",
261
+ type: "boolean",
262
+ default: false
263
+ }),
264
+ NewOpt("baseRange", {
265
+ descTemplate: "Provide start and end time for base object id (format START_TIME:END_TIME)",
266
+ type: "string",
267
+ }),
268
+ NewOpt("baseOfferingKey", {
269
+ descTemplate: "Offering key to use for base object if no objects in items list",
270
+ type: "string",
271
+ default: ""
272
+ })
273
+ ]
274
+ };
275
+ }
276
+
277
+ async body() {
278
+ const logger = this.logger;
279
+ const { items, libraryId, baseObjectId, baseRange, baseOfferingKey } = this.args;
280
+
281
+ let baseObjectStartTC , baseObjectEndTC ;
282
+ if(baseRange) {
283
+ const parts = baseRange.split(":");
284
+ baseObjectStartTC = parts.length >= 1 ? parts[0] : undefined;
285
+ baseObjectEndTC = parts.length >= 2? parts[1]:undefined;
286
+ }
287
+
288
+ // Fetch base object metadata first
289
+ const baseMetadata = await this.concerns.FabricObject.metadata({
290
+ libraryId,
291
+ objectId: baseObjectId
292
+ });
293
+
294
+
295
+
296
+ // Determine base offering
297
+ const baseOfferingKeyUsed = baseOfferingKey || Object.keys(baseMetadata.offerings ?? {})[0] || "default";
298
+
299
+ const name = this.args.name || baseMetadata.public?.name + " - " + baseOfferingKeyUsed || baseObjectId;
300
+ logger.log(`Creating composition '${name}' on base object ${baseObjectId}`);
301
+
302
+ // Parse items
303
+ let itemList = items? items.split(",").map(itemParser):[];
304
+
305
+ // Add baseObjectId to itemList if not already included
306
+ if (!itemList.some(item => item.objectId === baseObjectId)) {
307
+ itemList.unshift({ objectId: baseObjectId, offering: baseOfferingKeyUsed, startTC: baseObjectStartTC, endTC: baseObjectEndTC});
308
+ }
309
+
310
+ logger.log("\nChecking items for any parameter mismatches...");
311
+
312
+ const itemPublicMeta = [];
313
+ const itemOfferings = [];
314
+
315
+ // Fetch metadata for each item
316
+ for (const item of itemList) {
317
+ if (item.objectId === baseObjectId) {
318
+ itemPublicMeta.push(baseMetadata);
319
+ const offering = baseMetadata.offerings?.[item.offering];
320
+ if (!offering) {
321
+ throw Error(`Offering '${item.offering}' not found for ${item.objectId}`);
322
+ }
323
+ itemOfferings.push(offering);
324
+ continue;
325
+ }
326
+ const meta = await this.concerns.FabricObject.metadata({
327
+ libraryId,
328
+ objectId: item.objectId
329
+ });
330
+ itemPublicMeta.push(meta);
331
+ const offering = meta.offerings?.[item.offering];
332
+ if (!offering) throw Error(`Offering '${item.offering}' not found for ${item.objectId}`);
333
+ itemOfferings.push(offering);
334
+ }
335
+
336
+ // Use first item as reference for checks
337
+ const firstPoStreams = R.map(
338
+ withoutEncryptionSchemes,
339
+ itemOfferings[0].playout.streams
340
+ );
341
+ const firstPoFormats = R.map(
342
+ withoutDrmContentId,
343
+ itemOfferings[0].playout.playout_formats
344
+ );
345
+ const firstMsStreamFields = msStreamFields(
346
+ firstPoStreams,
347
+ itemOfferings[0].media_struct.streams
348
+ );
349
+
350
+ // Validate all other items match first item
351
+ for (let i = 1; i < itemList.length; i++) {
352
+ const testPoStreams = R.map(
353
+ withoutEncryptionSchemes,
354
+ itemOfferings[i].playout.streams
355
+ );
356
+ const stripTranscodeId = R.map(
357
+ R.evolve({ representations: R.map(R.dissoc("transcode_id")) })
358
+ );
359
+ if (!R.equals(stripTranscodeId(firstPoStreams), stripTranscodeId(testPoStreams))) {
360
+ throw Error(
361
+ "ERROR: All items must have identical playout.streams.representations"
362
+ );
363
+ }
364
+
365
+ const testPoFormats = R.map(
366
+ withoutDrmContentId,
367
+ itemOfferings[i].playout.playout_formats
368
+ );
369
+ if (!R.equals(firstPoFormats, testPoFormats)) {
370
+ throw Error(
371
+ "ERROR: All items must have identical playout.playout_formats"
372
+ );
373
+ }
374
+
375
+ const testMsStreamFields = msStreamFields(
376
+ testPoStreams,
377
+ itemOfferings[i].media_struct.streams
378
+ );
379
+ if (!R.equals(firstMsStreamFields, testMsStreamFields)) {
380
+ throw Error(
381
+ "ERROR: All items must have matching media_struct stream fields: " +
382
+ STREAM_FIELDS.join(", ")
383
+ );
384
+ }
385
+ }
386
+
387
+ logger.log("Mezzanine item parameter checks passed.");
388
+
389
+ logger.log("\nAdding channel metadata to new object...");
390
+
391
+ // Prepare channel metadata
392
+ const key = sanitizeFilename(name, `${baseObjectId}.mp4`);
393
+
394
+ const existingChannelOffering = baseMetadata.channel?.offerings?.[key];
395
+
396
+ if (existingChannelOffering && !this.args.force) {
397
+ throw Error(
398
+ `ERROR: A composition named '${name}' already exists on object ${baseObjectId} ` +
399
+ `(key='${key}'). Use --force to overwrite it.`
400
+ );
401
+ } else if (existingChannelOffering && this.args.force) {
402
+ logger.log(
403
+ `Warning: Overwriting existing composition '${name}' (key='${key}') due to --force`
404
+ );
405
+
406
+ }
407
+
408
+
409
+ // Base offering for channel
410
+ const baseOffering = baseMetadata.offerings?.[baseOfferingKeyUsed] || {};
411
+
412
+ // Normalize playout_formats and streams to arrays
413
+ const basePlayoutFormats =
414
+ baseOffering.playout?.playout_formats &&
415
+ typeof baseOffering.playout.playout_formats === "object" &&
416
+ !Array.isArray(baseOffering.playout.playout_formats)
417
+ ? baseOffering.playout.playout_formats
418
+ : {};
419
+
420
+ const baseStreams =
421
+ baseOffering.playout?.streams &&
422
+ typeof baseOffering.playout.streams === "object" &&
423
+ !Array.isArray(baseOffering.playout.streams)
424
+ ? baseOffering.playout.streams
425
+ : {};
426
+
427
+ const newChannelOffering = {
428
+ created_at: "",
429
+ display_name: name,
430
+ items: [],
431
+ key,
432
+ offeringKey: baseOfferingKeyUsed,
433
+ playout: {
434
+ playout_formats: basePlayoutFormats,
435
+ streams: baseStreams
436
+ },
437
+ playout_type: "ch_vod",
438
+ source_info: {
439
+ frameRate: baseOffering.media_struct.streams.video.rate,
440
+ libraryId,
441
+ name: baseMetadata.public?.name || name,
442
+ objectId: baseObjectId,
443
+ offeringKey: baseOfferingKeyUsed,
444
+ profileKey: "",
445
+ prompt: "",
446
+ type: "elvmediatool"
447
+ },
448
+ sources: [],
449
+ updated_at: ""
450
+ };
451
+
452
+ // Add items and sources
453
+ for (let i = 0; i < itemList.length; i++) {
454
+ const item = itemList[i];
455
+ const offering = itemOfferings[i];
456
+ const publicMeta = itemPublicMeta[i];
457
+ const startTC = itemList[i].startTC;
458
+ const endTC = itemList[i].endTC;
459
+
460
+ const itemLatestVersion = await this.concerns.FabricObject.latestVersionHash({
461
+ libraryId,
462
+ objectId: item.objectId
463
+ });
464
+
465
+ const derivedSlice = deriveSliceAndDurationFromVideoStream({offering, startTC, endTC});
466
+
467
+ newChannelOffering.items.push({
468
+ display_name: publicMeta.public.name,
469
+ duration_rat: derivedSlice.duration_rat,
470
+ slice_start_rat: derivedSlice.slice_start_rat,
471
+ slice_end_rat: derivedSlice.slice_end_rat,
472
+ source: {
473
+ ".": { auto_update: { tag: "latest" } },
474
+ "/": `/qfab/${itemLatestVersion}/rep/playout/${item.offering}`
475
+ },
476
+ type: "mez_vod"
477
+ });
478
+
479
+ // Add item to sources array
480
+ newChannelOffering.sources.push(item.objectId);
481
+
482
+ const now = new Date().toISOString();
483
+
484
+ if (!newChannelOffering.created_at) {
485
+ newChannelOffering.created_at = now; // first write only
486
+ }
487
+
488
+ newChannelOffering.updated_at = now; // always overwrite
489
+ }
490
+
491
+ logger.log("Merging metadata...");
492
+
493
+ const versionHash = await this.concerns.Metadata.write({
494
+ libraryId,
495
+ metadataSubtree: `/channel/offerings/${key}`,
496
+ metadata: {
497
+ ...newChannelOffering
498
+ },
499
+ objectId: baseObjectId,
500
+ commitMessage: "Ran CompositionCreate.js"
501
+ });
502
+
503
+ logger.log("");
504
+ logger.log(`Object ID: ${baseObjectId}`);
505
+ logger.log("New version hash: " + versionHash);
506
+ }
507
+
508
+ header() {
509
+ return `Create composition in object ${this.args.baseObjectId}`;
510
+ }
511
+ }
512
+
513
+ if (require.main === module) {
514
+ Utility.cmdLineInvoke(CompositionCreate);
515
+ } else {
516
+ module.exports = CompositionCreate;
517
+ }