@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.
- package/README.md +2 -11
- package/dist/index.js +2 -0
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/feed-fetcher.js +34 -4
- package/dist/src/utilities/nft-indexer.js +50 -20
- package/dist/src/utilities/playlist-builder.js +187 -209
- package/dist/src/utilities/ref-manifest.js +226 -0
- package/docs/EXAMPLES.md +42 -0
- package/docs/PROJECT_SPEC.md +199 -1
- package/docs/README.md +39 -9
- package/docs/RELEASING.md +3 -42
- package/package.json +3 -3
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Playlist Builder Utilities
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
60
|
+
* @returns {{ duration?: number, loopFalse?: boolean }}
|
|
70
61
|
*/
|
|
71
|
-
function
|
|
62
|
+
function resolveItemTiming(mediaHints = {}, duration) {
|
|
72
63
|
if (typeof duration === 'number') {
|
|
73
|
-
|
|
74
|
-
return item;
|
|
64
|
+
return { duration };
|
|
75
65
|
}
|
|
76
66
|
if (isTimeBasedMedia(mediaHints.mimeType, mediaHints.sourceUrl)) {
|
|
77
|
-
|
|
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
|
-
|
|
75
|
+
return { duration: generativeDuration };
|
|
87
76
|
}
|
|
88
|
-
return
|
|
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
|
-
*
|
|
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()
|
|
180
|
+
const base = (value || '').toString().trim();
|
|
136
181
|
if (!base) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.
|
|
144
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
return
|
|
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
|
|
389
|
+
* Build a complete DP-1 v1.1.0 playlist via PlaylistBuilder.
|
|
344
390
|
*
|
|
345
|
-
* Creates a
|
|
346
|
-
* Supports both object parameter and legacy
|
|
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: '
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
title
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
*
|
|
499
|
+
* validateDP1Playlist schema-validates a playlist via dp1-js ValidatePlaylist.
|
|
459
500
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
};
|