@feralfile/cli 2.2.3 → 2.4.0

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.
@@ -1,9 +1,18 @@
1
1
  /**
2
2
  * Playlist Builder Utilities
3
- * Core functions for building and validating DP1 playlists
3
+ *
4
+ * Constructs DP-1 playlists and items via dp1-js document/leaf builders so
5
+ * every generated object is schema-validated against the DP-1 AJV defs before
6
+ * optional signing. Domain timing heuristics (DP-1 §4.1) stay in this module;
7
+ * leaf shapes (display, provenance, contract) come from dp1-js, and inline Ref
8
+ * Manifests (Playlist Extension §3.6) come from ./ref-manifest.
4
9
  */
10
+ const { PlaylistBuilder, PlaylistItemBuilder, DisplayPrefsBuilder, ProvenanceBuilder, ContractBuilder, ValidatePlaylist, slugify: dp1Slugify, generateId, } = require('dp1-js');
5
11
  const { getPlaylistConfig, getConfig } = require('../config');
6
12
  const { signPlaylist } = require('./playlist-signer');
13
+ const { buildInlineManifestForToken } = require('./ref-manifest');
14
+ /** Default display background that passes DP-1 hex color validation. */
15
+ const DEFAULT_BACKGROUND = '#111111';
7
16
  /**
8
17
  * isTimeBasedMedia reports whether an item's media has an intrinsic runtime
9
18
  * (video or audio) per DP-1 §4.1 "time-based sources".
@@ -41,41 +50,21 @@ function isInteractiveWeb(mimeType, sourceUrl) {
41
50
  return candidates.some((mime) => mime.startsWith('text/html'));
42
51
  }
43
52
  /**
44
- * applyItemTiming sets a DP1 item's playback timing per DP-1 §4.1.
45
- *
46
- * Invariant this function owns: an explicit numeric `duration` always wins
47
- * and is stamped as-is. When `duration` is undefined/null ("auto"):
48
- * - a time-based source (video/audio) gets NO duration and
49
- * `display.loop: false`, so a conformant player MUST advance at
50
- * end-of-stream — the media plays its natural length;
51
- * - an interactive web page (HTML) — generative art with no intrinsic
52
- * runtime and no end-of-stream event — gets the configured
53
- * `generativeDuration` (default 60s) so the playlist rotates. Set
54
- * `generativeDuration` to 0 to omit it and have a conformant player park
55
- * on the work open-ended instead;
56
- * - static and code-based sources fall back to the configured
57
- * `defaultDuration` because they have no intrinsic runtime to play out.
58
- *
59
- * Do not stamp a duration on video/audio: it silently truncates or pads them.
60
- * (Earlier this also omitted duration for HTML works, which left them with no
61
- * duration field. Players that require the field — notably FF1 — then rejected
62
- * the entire playlist, so generative works now carry an explicit duration.)
53
+ * resolveItemTiming computes duration/loop decisions per DP-1 §4.1 without
54
+ * mutating a playlist item. Callers stamp the result through PlaylistItemBuilder.
63
55
  *
64
- * @param {Object} item - DP1 playlist item (mutated in place)
65
56
  * @param {Object} mediaHints - Media signals for the time-based check
66
57
  * @param {string} [mediaHints.mimeType] - Indexer-reported MIME type
67
58
  * @param {string} [mediaHints.sourceUrl] - Item source URL
68
59
  * @param {number} [duration] - Explicit display seconds; omit for auto
69
- * @returns {Object} The same item, for chaining
60
+ * @returns {{ duration?: number, loopFalse?: boolean }}
70
61
  */
71
- function applyItemTiming(item, mediaHints = {}, duration) {
62
+ function resolveItemTiming(mediaHints = {}, duration) {
72
63
  if (typeof duration === 'number') {
73
- item.duration = duration;
74
- return item;
64
+ return { duration };
75
65
  }
76
66
  if (isTimeBasedMedia(mediaHints.mimeType, mediaHints.sourceUrl)) {
77
- item.display = { ...(item.display || {}), loop: false };
78
- return item;
67
+ return { loopFalse: true };
79
68
  }
80
69
  if (isInteractiveWeb(mediaHints.mimeType, mediaHints.sourceUrl)) {
81
70
  // Generative/interactive works have no intrinsic runtime, so stamp the
@@ -83,13 +72,71 @@ function applyItemTiming(item, mediaHints = {}, duration) {
83
72
  // value of 0 means "omit" — a conformant player then parks open-ended.
84
73
  const generativeDuration = getGenerativeDuration();
85
74
  if (generativeDuration > 0) {
86
- item.duration = generativeDuration;
75
+ return { duration: generativeDuration };
87
76
  }
88
- return item;
77
+ return {};
78
+ }
79
+ return { duration: getDefaultStaticDuration() };
80
+ }
81
+ /**
82
+ * applyItemTiming sets a DP1 item's playback timing per DP-1 §4.1.
83
+ *
84
+ * Kept for unit tests and callers that mutate plain objects. New construction
85
+ * paths prefer resolveItemTiming + PlaylistItemBuilder so the final item is
86
+ * schema-validated by dp1-js.
87
+ *
88
+ * @param {Object} item - DP1 playlist item (mutated in place)
89
+ * @param {Object} mediaHints - Media signals for the time-based check
90
+ * @param {string} [mediaHints.mimeType] - Indexer-reported MIME type
91
+ * @param {string} [mediaHints.sourceUrl] - Item source URL
92
+ * @param {number} [duration] - Explicit display seconds; omit for auto
93
+ * @returns {Object} The same item, for chaining
94
+ */
95
+ function applyItemTiming(item, mediaHints = {}, duration) {
96
+ const timing = resolveItemTiming(mediaHints, duration);
97
+ if (typeof timing.duration === 'number') {
98
+ item.duration = timing.duration;
99
+ }
100
+ if (timing.loopFalse) {
101
+ item.display = { ...(item.display || {}), loop: false };
89
102
  }
90
- item.duration = getDefaultStaticDuration();
91
103
  return item;
92
104
  }
105
+ /**
106
+ * buildDefaultDisplay builds schema-valid DisplayPrefs via dp1-js.
107
+ *
108
+ * @param {{ loopFalse?: boolean }} [opts]
109
+ * @returns {object} Validated DisplayPrefs
110
+ */
111
+ function buildDefaultDisplay(opts = {}) {
112
+ const builder = new DisplayPrefsBuilder().scaling('fit').background(DEFAULT_BACKGROUND).margin(0);
113
+ if (opts.loopFalse) {
114
+ builder.loop(false);
115
+ }
116
+ return builder.build();
117
+ }
118
+ /**
119
+ * applyTimingToItemBuilder stamps resolveItemTiming results onto a builder.
120
+ *
121
+ * @param {InstanceType<typeof PlaylistItemBuilder>} itemBuilder
122
+ * @param {Object} mediaHints
123
+ * @param {number} [duration]
124
+ * @param {object} [baseDisplay] - Already-built DisplayPrefs without loop
125
+ * @returns {InstanceType<typeof PlaylistItemBuilder>}
126
+ */
127
+ function applyTimingToItemBuilder(itemBuilder, mediaHints, duration, baseDisplay) {
128
+ const timing = resolveItemTiming(mediaHints, duration);
129
+ if (typeof timing.duration === 'number') {
130
+ itemBuilder.durationSeconds(timing.duration);
131
+ }
132
+ if (timing.loopFalse) {
133
+ itemBuilder.display(buildDefaultDisplay({ loopFalse: true }));
134
+ }
135
+ else if (baseDisplay) {
136
+ itemBuilder.display(baseDisplay);
137
+ }
138
+ return itemBuilder;
139
+ }
93
140
  /**
94
141
  * getDefaultStaticDuration returns the configured per-item display seconds
95
142
  * for media without an intrinsic runtime. Falls back to 10 when config is
@@ -123,31 +170,30 @@ function getGenerativeDuration() {
123
170
  }
124
171
  }
125
172
  /**
126
- * Convert a string to a URL-friendly slug
127
- *
128
- * Lowercases, trims, replaces whitespace with dashes, and strips invalid chars.
129
- * Falls back to a short id when input is empty.
173
+ * slugify converts free text to a kebab-case slug via dp1-js.
174
+ * Falls back to a short id when input is empty (dp1-js slugify throws then).
130
175
  *
131
176
  * @param {string} value - Source string to slugify
132
177
  * @returns {string} Slugified string
133
178
  */
134
179
  function slugify(value) {
135
- const base = (value || '').toString().trim().toLowerCase();
180
+ const base = (value || '').toString().trim();
136
181
  if (!base) {
137
- const crypto = require('crypto');
138
- return `playlist-${crypto.randomUUID().split('-')[0]}`;
139
- }
140
- return base
141
- .normalize('NFD')
142
- .replace(/[\u0300-\u036f]/g, '') // strip diacritics
143
- .replace(/[^a-z0-9\s-]/g, '') // remove invalid chars
144
- .replace(/\s+/g, '-') // spaces -> dashes
145
- .replace(/-+/g, '-') // collapse dashes
146
- .replace(/^-|-$/g, ''); // trim dashes
182
+ return `playlist-${generateId().split('-')[0]}`;
183
+ }
184
+ try {
185
+ return dp1Slugify(base);
186
+ }
187
+ catch (_error) {
188
+ return `playlist-${generateId().split('-')[0]}`;
189
+ }
147
190
  }
148
191
  /**
149
192
  * Convert single NFT token info to DP1 playlist item
150
193
  *
194
+ * When the token carries description, artist, or a still image distinct from
195
+ * the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
196
+ *
151
197
  * @param {Object} tokenInfo - Token information from NFT indexer
152
198
  * @param {number} [duration] - Explicit display seconds; omit for auto timing
153
199
  * (video/audio play natural length per DP-1 §4.1, static media uses the
@@ -187,41 +233,41 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration) {
187
233
  fa2: 'fa2',
188
234
  };
189
235
  const standard = standardMap[token.standard?.toLowerCase()] || 'other';
190
- // Generate unique ID for the item (UUID v4 format)
191
- const crypto = require('crypto');
192
- const itemId = crypto.randomUUID();
193
- // Build DP1 item structure according to OpenAPI spec
194
- const dp1Item = {
195
- id: itemId,
196
- title: token.name || `Token #${token.tokenId}`,
197
- source: sourceUrl,
198
- license: 'token', // NFTs are token-gated by default
199
- created: new Date().toISOString(),
200
- provenance: {
201
- type: 'onChain',
202
- contract: {
203
- chain: chain,
204
- standard: standard,
205
- address: token.contractAddress,
206
- tokenId: String(token.tokenId),
207
- },
208
- },
209
- };
210
- // Add display preferences if available
211
- dp1Item.display = {
212
- scaling: 'fit',
213
- background: '#111',
214
- margin: 0,
215
- };
216
- // Add metadata URI if available
217
- if (token.metadata?.uri || token.tokenURI) {
218
- dp1Item.provenance.contract.uri = token.metadata?.uri || token.tokenURI;
219
- }
220
- // Add reference to image if animation_url was used as source
221
- if ((token.animation_url || token.animationUrl) && (token.image?.url || token.image)) {
222
- dp1Item.ref = token.image?.url || token.image;
223
- }
224
- return applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
236
+ const contractBuilder = new ContractBuilder()
237
+ .chain(chain)
238
+ .standard(standard)
239
+ .address(token.contractAddress)
240
+ .tokenId(String(token.tokenId));
241
+ // Add metadata URI if available (contract.uri is the DP-1 field)
242
+ const metadataUri = token.metadata?.uri || token.tokenURI;
243
+ if (metadataUri) {
244
+ contractBuilder.uri(metadataUri);
245
+ }
246
+ const itemBuilder = new PlaylistItemBuilder()
247
+ .id(generateId())
248
+ .title(token.name || `Token #${token.tokenId}`)
249
+ .source(sourceUrl)
250
+ .license('token')
251
+ .provenance(new ProvenanceBuilder().type('onChain').contract(contractBuilder));
252
+ // No `ref`. This used to set `ref` to `token.image.url` under a comment
253
+ // claiming it referenced the still image, but on the indexer path
254
+ // `image.url` is getBestMediaUrl's pick, which prefers `animation_url` —
255
+ // so for a dynamic work `ref` resolved to the animation URL, byte-identical
256
+ // to `source`. It never carried a poster frame. The still image lives in
257
+ // `image.thumbnail`, which that branch never read, and which the inline
258
+ // manifest below now carries as `metadata.thumbnails.default`.
259
+ //
260
+ // Emitting nothing is also the honest reading of DP-1: `ref` is a URI to an
261
+ // external Ref Manifest (and needs `refHash` over HTTPS), which a locally
262
+ // built playlist has nowhere to host — Playlist Extension §3.6 names this
263
+ // CLI as exactly that case. Add `ref` back only when there is a real
264
+ // manifest to point it at.
265
+ const inlineManifest = buildInlineManifestForToken(token, { sourceUrl });
266
+ if (inlineManifest) {
267
+ itemBuilder.inlineManifest(inlineManifest);
268
+ }
269
+ applyTimingToItemBuilder(itemBuilder, { mimeType: token.image?.mimeType, sourceUrl }, duration, buildDefaultDisplay());
270
+ return itemBuilder.build();
225
271
  }
226
272
  /**
227
273
  * Convert NFT token info(s) to DP1 playlist item(s)
@@ -340,10 +386,11 @@ function generatePlaylistTitle(items) {
340
386
  return `DP-1 Playlist (${items.length} ${items.length === 1 ? 'item' : 'items'})`;
341
387
  }
342
388
  /**
343
- * Build complete DP1 v1.0.0 compliant playlist
389
+ * Build a complete DP-1 v1.1.0 playlist via PlaylistBuilder.
344
390
  *
345
- * Creates a complete playlist structure with metadata, defaults, and optional signature.
346
- * Supports both object parameter and legacy separate parameters for backward compatibility.
391
+ * Creates a schema-validated unsigned playlist, then optionally signs when a
392
+ * private key is configured. Supports both object parameter and legacy
393
+ * separate parameters.
347
394
  *
348
395
  * @param {Object|Array} paramsOrItems - Playlist parameters object or items array (legacy)
349
396
  * @param {Array} [paramsOrItems.items] - Array of DP1 items
@@ -372,7 +419,7 @@ function generatePlaylistTitle(items) {
372
419
  * title: 'Test',
373
420
  * deterministicMode: true,
374
421
  * fixedTimestamp: '2024-01-01T00:00:00.000Z',
375
- * fixedId: 'playlist_test_123'
422
+ * fixedId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
376
423
  * });
377
424
  */
378
425
  async function buildDP1Playlist(paramsOrItems, options = {}) {
@@ -390,7 +437,7 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
390
437
  items = paramsOrItems;
391
438
  ({ title, slug, deterministicMode, fixedTimestamp, fixedId } = options);
392
439
  }
393
- // Validate items
440
+ // Validate items early (PlaylistBuilder also rejects empty items without dynamicQuery)
394
441
  if (!Array.isArray(items) || items.length === 0) {
395
442
  throw new Error('Playlist must contain at least one item');
396
443
  }
@@ -414,32 +461,26 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
414
461
  if (!slug) {
415
462
  slug = slugify(title);
416
463
  }
417
- // Build DP1 playlist structure using the v1.1.0 envelope.
418
- // Support deterministic mode for testing (freeze timestamp and ID)
419
- const timestamp = deterministicMode && fixedTimestamp ? fixedTimestamp : new Date().toISOString();
420
- const crypto = require('crypto');
421
- const playlistId = deterministicMode && fixedId ? fixedId : crypto.randomUUID();
422
- const playlist = {
423
- dpVersion: '1.1.0',
424
- id: playlistId,
425
- title,
426
- created: timestamp,
427
- items,
428
- defaults: {
429
- display: {
430
- scaling: 'fit',
431
- background: '#111',
432
- margin: 0,
433
- },
434
- license: 'token',
435
- // No defaults.duration: items that should be timed carry an explicit
436
- // duration already, and a playlist-level default would make conformant
437
- // players time-cut items that intentionally omit duration to play
438
- // their natural length (DP-1 §4.1 end-of-stream advance).
439
- },
440
- };
441
- // Always include slug (auto-generated when missing)
442
- playlist.slug = slug;
464
+ // Build DP1 playlist structure using the v1.1.0 envelope via dp1-js.
465
+ // Support deterministic mode for testing (freeze timestamp and ID).
466
+ // No defaults.duration: items that should be timed carry an explicit
467
+ // duration already, and a playlist-level default would make conformant
468
+ // players time-cut items that intentionally omit duration to play
469
+ // their natural length (DP-1 §4.1 end-of-stream advance).
470
+ const playlistBuilder = new PlaylistBuilder()
471
+ .dpVersion('1.1.0')
472
+ .title(title)
473
+ .slug(slug)
474
+ .defaultDisplay(buildDefaultDisplay())
475
+ .defaultLicense('token')
476
+ .items(items);
477
+ if (deterministicMode && fixedId) {
478
+ playlistBuilder.id(fixedId);
479
+ }
480
+ if (deterministicMode && fixedTimestamp) {
481
+ playlistBuilder.created(fixedTimestamp);
482
+ }
483
+ const playlist = playlistBuilder.build();
443
484
  // Sign the playlist if private key is configured.
444
485
  try {
445
486
  const playlistConfig = getPlaylistConfig();
@@ -455,10 +496,10 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
455
496
  return playlist;
456
497
  }
457
498
  /**
458
- * Validate DP1 playlist structure according to OpenAPI spec
499
+ * validateDP1Playlist schema-validates a playlist via dp1-js ValidatePlaylist.
459
500
  *
460
- * Performs comprehensive validation of playlist structure, fields, and item requirements.
461
- * Returns detailed errors for each validation failure.
501
+ * Unsigned drafts are accepted (`requireSignatures: false`). Returns the
502
+ * historical `{ valid, errors }` shape for CLI/tests.
462
503
  *
463
504
  * @param {Object} playlist - DP1 playlist to validate
464
505
  * @returns {Object} Validation result
@@ -471,83 +512,21 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
471
512
  * }
472
513
  */
473
514
  function validateDP1Playlist(playlist) {
474
- const errors = [];
475
- // Check required playlist fields
476
- if (!playlist.dpVersion) {
477
- errors.push('Missing required field: dpVersion');
478
- }
479
- else if (typeof playlist.dpVersion !== 'string') {
480
- errors.push('Field "dpVersion" must be a string');
481
- }
482
- if (!playlist.title) {
483
- errors.push('Missing required field: title');
484
- }
485
- else if (playlist.title.length > 256) {
486
- errors.push('Field "title" must not exceed 256 characters');
487
- }
488
- if (!playlist.items) {
489
- errors.push('Missing required field: items');
490
- }
491
- else if (!Array.isArray(playlist.items)) {
492
- errors.push('Field "items" must be an array');
493
- }
494
- else if (playlist.items.length === 0) {
495
- errors.push('Playlist must contain at least one item');
496
- }
497
- else if (playlist.items.length > 1024) {
498
- errors.push('Playlist cannot contain more than 1024 items');
515
+ try {
516
+ ValidatePlaylist(playlist, { requireSignatures: false });
517
+ return { valid: true, errors: [] };
499
518
  }
500
- else {
501
- // Validate each item according to PlaylistItem schema
502
- playlist.items.forEach((item, index) => {
503
- if (!item.source) {
504
- errors.push(`Item ${index}: Missing required field "source"`);
505
- }
506
- else if (typeof item.source !== 'string') {
507
- errors.push(`Item ${index}: Field "source" must be a string (URI)`);
508
- }
509
- // Duration is OPTIONAL per the DP-1 v1.1.0 schema (PlaylistItem requires
510
- // only `source`; duration has `minimum: 0`). Absent duration is meaningful:
511
- // time-based sources advance at end-of-stream (§4.1). Do not tighten this
512
- // back to required — it would reject spec-valid playlists.
513
- if (item.duration !== undefined && item.duration !== null) {
514
- if (typeof item.duration !== 'number' || item.duration < 0) {
515
- errors.push(`Item ${index}: Field "duration" must be a number >= 0`);
516
- }
517
- }
518
- if (!item.license) {
519
- errors.push(`Item ${index}: Missing required field "license"`);
520
- }
521
- else if (!['open', 'token', 'subscription'].includes(item.license)) {
522
- errors.push(`Item ${index}: Field "license" must be one of: open, token, subscription`);
523
- }
524
- // Validate optional title length
525
- if (item.title && item.title.length > 256) {
526
- errors.push(`Item ${index}: Field "title" must not exceed 256 characters`);
527
- }
528
- // Validate optional provenance structure
529
- if (item.provenance) {
530
- if (!item.provenance.type) {
531
- errors.push(`Item ${index}: provenance.type is required when provenance is present`);
532
- }
533
- else if (!['onChain', 'seriesRegistry', 'offChainURI'].includes(item.provenance.type)) {
534
- errors.push(`Item ${index}: provenance.type must be one of: onChain, seriesRegistry, offChainURI`);
535
- }
536
- if (item.provenance.contract) {
537
- if (!item.provenance.contract.chain) {
538
- errors.push(`Item ${index}: provenance.contract.chain is required`);
539
- }
540
- else if (!['evm', 'tezos', 'bitmark', 'other'].includes(item.provenance.contract.chain)) {
541
- errors.push(`Item ${index}: provenance.contract.chain must be one of: evm, tezos, bitmark, other`);
542
- }
543
- }
544
- }
545
- });
519
+ catch (error) {
520
+ const details = Array.isArray(error?.details) ? error.details : [];
521
+ const errors = details.length > 0
522
+ ? details.map((detail) => {
523
+ const path = detail?.path ? String(detail.path) : '/';
524
+ const message = detail?.message ? String(detail.message) : 'validation failed';
525
+ return `${path}: ${message}`;
526
+ })
527
+ : [error instanceof Error ? error.message : String(error)];
528
+ return { valid: false, errors };
546
529
  }
547
- return {
548
- valid: errors.length === 0,
549
- errors,
550
- };
551
530
  }
552
531
  /**
553
532
  * Detect MIME type from a URL or file extension.
@@ -608,7 +587,7 @@ function detectMimeType(url) {
608
587
  return mimeTypes[extension] || '';
609
588
  }
610
589
  /**
611
- * Build a single DP1 playlist item from a URL
590
+ * Build a single DP1 playlist item from a URL via PlaylistItemBuilder.
612
591
  *
613
592
  * @param {string} url - Media URL
614
593
  * @param {number} [duration] - Explicit display seconds; omit for auto timing
@@ -645,31 +624,27 @@ function buildUrlItem(url, duration, options = {}) {
645
624
  title = 'URL Playback';
646
625
  }
647
626
  }
648
- const crypto = require('crypto');
649
- const itemId = crypto.randomUUID();
650
- const item = {
651
- id: itemId,
652
- title,
653
- source: sourceUrl,
654
- license: 'open',
655
- created: new Date().toISOString(),
656
- provenance: {
657
- type: 'offChainURI',
658
- uri: sourceUrl,
659
- },
660
- display: {
661
- scaling: 'fit',
662
- background: '#111',
663
- margin: 0,
664
- },
665
- };
627
+ // No inline Ref Manifest here, deliberately: an off-chain URL item holds only
628
+ // url, title, and mimeType. `mimeType` is a timing hint with no place in
629
+ // DP-1 Metadata, and `title` is already written to the item below — so a
630
+ // manifest would carry nothing the item does not already say. If a resolver
631
+ // ever supplies a real description or artist, route it through
632
+ // buildInlineManifestForToken rather than lowering that bar.
633
+ const itemBuilder = new PlaylistItemBuilder()
634
+ .id(generateId())
635
+ .title(title)
636
+ .source(sourceUrl)
637
+ .license('open')
638
+ // offChainURI has no provenance.uri in the DP-1 schema — only type.
639
+ .provenance(new ProvenanceBuilder().type('offChainURI'));
666
640
  // A URL handed to `play` that isn't a recognized media file is treated as an
667
641
  // interactive web page: the FF1 renders unknown URLs in a sandboxed iframe,
668
642
  // so an explicit caller hint aside, "unknown" means web, not static image.
669
643
  // This routes such items through the open-ended (no-duration) timing path
670
644
  // instead of the static-image default.
671
645
  const mimeHint = options.mimeType || (detectMimeType(sourceUrl) === '' ? 'text/html' : undefined);
672
- return applyItemTiming(item, { sourceUrl, mimeType: mimeHint }, duration);
646
+ applyTimingToItemBuilder(itemBuilder, { sourceUrl, mimeType: mimeHint }, duration, buildDefaultDisplay());
647
+ return itemBuilder.build();
673
648
  }
674
649
  module.exports = {
675
650
  convertTokenToDP1Item,
@@ -682,5 +657,8 @@ module.exports = {
682
657
  isTimeBasedMedia,
683
658
  isInteractiveWeb,
684
659
  applyItemTiming,
660
+ resolveItemTiming,
661
+ applyTimingToItemBuilder,
685
662
  buildUrlItem,
663
+ buildDefaultDisplay,
686
664
  };