@feralfile/cli 2.2.3 → 2.3.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/package.json +3 -3
- package/dist/src/utilities/feed-fetcher.js +34 -4
- package/dist/src/utilities/nft-indexer.js +41 -20
- package/dist/src/utilities/playlist-builder.js +187 -209
- package/dist/src/utilities/ref-manifest.js +226 -0
- package/docs/EXAMPLES.md +4 -0
- package/docs/PROJECT_SPEC.md +77 -1
- package/docs/README.md +1 -9
- package/docs/RELEASING.md +3 -42
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,15 +12,7 @@ ff-cli is a set of deterministic commands for building DP-1–conformant playlis
|
|
|
12
12
|
npm i -g @feralfile/cli
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
This
|
|
16
|
-
|
|
17
|
-
## Install (curl)
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
curl -fsSL https://feralfile.com/ff-cli-install | bash
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Installs a prebuilt binary for macOS/Linux. Node.js 22+ must still be on your PATH (the bundle runs under `node`). If a command fails right after install, prefer the npm install above.
|
|
15
|
+
This installs a complete package (including `config.json.example` and the bundled `dp1-js`). Requires Node.js 22+.
|
|
24
16
|
|
|
25
17
|
## One-off Usage (npx)
|
|
26
18
|
|
|
@@ -138,8 +130,7 @@ Run the same command locally before opening a PR. It checks formatting, lint, te
|
|
|
138
130
|
|
|
139
131
|
Other GitHub Actions workflows:
|
|
140
132
|
|
|
141
|
-
- `.github/workflows/
|
|
142
|
-
- `.github/workflows/release.yml` reuses CI, verifies the release version, publishes npm, uploads assets, and checks the published release.
|
|
133
|
+
- `.github/workflows/release.yml` reuses CI, verifies the release version, publishes to npm, and checks the published package.
|
|
143
134
|
- `.github/workflows/dependency-review.yml` reviews dependency changes on pull requests.
|
|
144
135
|
- `.github/workflows/codeql.yml` runs CodeQL analysis on pull requests and pushes to `main`/`master`.
|
|
145
136
|
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"author": "",
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@napi-rs/keyring": "1.3.0",
|
|
62
61
|
"@feralfile/source-resolver": "^1.0.0",
|
|
62
|
+
"@napi-rs/keyring": "1.3.0",
|
|
63
63
|
"axios": "^1.6.2",
|
|
64
64
|
"bonjour-service": "^1.3.0",
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
67
67
|
"dotenv": "^16.3.1",
|
|
68
|
-
"dp1-js": "2.
|
|
68
|
+
"dp1-js": "2.3.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
71
|
"viem": "^2.38.2"
|
|
@@ -329,6 +329,25 @@ function shuffleArray(array) {
|
|
|
329
329
|
}
|
|
330
330
|
return shuffled;
|
|
331
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Normalize a legacy item `provenance` block to the DP-1 schema shape.
|
|
334
|
+
*
|
|
335
|
+
* A prior `buildUrlItem` stamped off-chain URL items with
|
|
336
|
+
* `provenance: { type: 'offChainURI', uri }`, but the DP-1 ProvenanceBlock has
|
|
337
|
+
* no top-level `uri` — a `uri` only exists under `provenance.contract`. Drop the
|
|
338
|
+
* stray top-level `uri` so rebuilding a legacy feed playlist produces the same
|
|
339
|
+
* clean shape as freshly-built items; every other provenance field is preserved.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} provenance - Item provenance block (already known to be an object)
|
|
342
|
+
* @returns {Object} Provenance without a stray top-level `uri`
|
|
343
|
+
*/
|
|
344
|
+
function normalizeProvenance(provenance) {
|
|
345
|
+
if (!('uri' in provenance)) {
|
|
346
|
+
return provenance;
|
|
347
|
+
}
|
|
348
|
+
const { uri: _uri, ...rest } = provenance;
|
|
349
|
+
return rest;
|
|
350
|
+
}
|
|
332
351
|
/**
|
|
333
352
|
* Get playlist items from a playlist
|
|
334
353
|
*
|
|
@@ -352,11 +371,22 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
|
|
|
352
371
|
// Apply an explicit duration override when one was requested; otherwise
|
|
353
372
|
// keep each item's own timing (including intentionally absent duration,
|
|
354
373
|
// which DP-1 §4.1 uses for natural-length playback of video/audio).
|
|
374
|
+
//
|
|
375
|
+
// Strip any item-level `created`: the DP-1 PlaylistItem schema has no
|
|
376
|
+
// `created` field (it lives only at the playlist level), so drop it here —
|
|
377
|
+
// including from legacy feed entries that may still carry it — before the
|
|
378
|
+
// items reach PlaylistBuilder for construction.
|
|
379
|
+
//
|
|
380
|
+
// Also normalize legacy `offChainURI` provenance: an earlier `buildUrlItem`
|
|
381
|
+
// emitted `provenance: { type: 'offChainURI', uri }`, but the DP-1 schema has
|
|
382
|
+
// no top-level `provenance.uri` (a `uri` only lives under `provenance.contract`).
|
|
383
|
+
// Rebuilding a legacy feed playlist would otherwise carry that stray field
|
|
384
|
+
// through PlaylistBuilder into the output, so drop it during extraction.
|
|
355
385
|
items = items.map((item) => {
|
|
356
|
-
const next =
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
386
|
+
const { created: _created, ...next } = item;
|
|
387
|
+
if (next.provenance && typeof next.provenance === 'object') {
|
|
388
|
+
next.provenance = normalizeProvenance(next.provenance);
|
|
389
|
+
}
|
|
360
390
|
if (typeof duration === 'number') {
|
|
361
391
|
next.duration = duration;
|
|
362
392
|
}
|
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
* This module provides functions to interact with NFT indexing services
|
|
4
4
|
* to retrieve comprehensive token information.
|
|
5
5
|
*/
|
|
6
|
-
const
|
|
6
|
+
const { PlaylistItemBuilder, ProvenanceBuilder, ContractBuilder } = require('dp1-js');
|
|
7
|
+
// Production by default. INDEXER_API_URL exists so tests can point the
|
|
8
|
+
// client at a local mock, the same way RASTER_API_URL already works — it
|
|
9
|
+
// is not user-facing configuration, which stays removed (initializeIndexer).
|
|
10
|
+
const GRAPHQL_ENDPOINT = process.env.INDEXER_API_URL || 'https://indexer.feralfile.com/graphql';
|
|
7
11
|
const { fetchWithTimeout } = require('./http');
|
|
8
12
|
const logger = require('../logger');
|
|
9
|
-
const {
|
|
13
|
+
const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
|
|
14
|
+
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
10
15
|
// Polling configuration (in milliseconds)
|
|
11
16
|
const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
|
|
12
17
|
const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
|
|
@@ -355,6 +360,9 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
|
355
360
|
* Extracts source URL from indexer data with proper priority:
|
|
356
361
|
* animation_url > image.url, ensuring the best quality media is used.
|
|
357
362
|
*
|
|
363
|
+
* When the token carries description, artist, or a still image distinct from
|
|
364
|
+
* the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
|
|
365
|
+
*
|
|
358
366
|
* @param {Object} tokenData - Token data in standard format
|
|
359
367
|
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
360
368
|
* (video/audio play natural length per DP-1 §4.1)
|
|
@@ -434,27 +442,40 @@ function convertToDP1Item(tokenData, duration) {
|
|
|
434
442
|
tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
|
|
435
443
|
bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
|
|
436
444
|
};
|
|
437
|
-
// Build
|
|
438
|
-
const
|
|
439
|
-
id
|
|
440
|
-
source
|
|
441
|
-
license
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
standard: token.standard || detectTokenStandard(token.chain, token.contractAddress),
|
|
448
|
-
address: token.contractAddress,
|
|
449
|
-
tokenId: String(token.tokenId),
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
};
|
|
445
|
+
// Build via dp1-js PlaylistItemBuilder so leaf blocks match DP-1 AJV schema.
|
|
446
|
+
const itemBuilder = new PlaylistItemBuilder()
|
|
447
|
+
.id(itemId)
|
|
448
|
+
.source(sourceUrl)
|
|
449
|
+
.license('open')
|
|
450
|
+
.provenance(new ProvenanceBuilder().type('onChain').contract(new ContractBuilder()
|
|
451
|
+
.chain(chainMap[token.chain.toLowerCase()] || 'other')
|
|
452
|
+
.standard(token.standard || detectTokenStandard(token.chain, token.contractAddress))
|
|
453
|
+
.address(token.contractAddress)
|
|
454
|
+
.tokenId(String(token.tokenId))));
|
|
453
455
|
// Add title if available (valid DP1 field)
|
|
454
456
|
if (token.name) {
|
|
455
|
-
|
|
457
|
+
itemBuilder.title(token.name);
|
|
458
|
+
}
|
|
459
|
+
// Carry description, artist, and still image inline (DP-1 Playlist Extension
|
|
460
|
+
// §3.6). These are the fields the indexer returns that have no PlaylistItem
|
|
461
|
+
// home; before inline manifests existed they were mapped and then dropped on
|
|
462
|
+
// the floor here. The helper returns undefined when the token has nothing
|
|
463
|
+
// beyond its title, so thin tokens stay thin.
|
|
464
|
+
const inlineManifest = buildInlineManifestForToken(token, { sourceUrl });
|
|
465
|
+
if (inlineManifest) {
|
|
466
|
+
itemBuilder.inlineManifest(inlineManifest);
|
|
467
|
+
}
|
|
468
|
+
applyTimingToItemBuilder(itemBuilder, { mimeType: token.image?.mimeType, sourceUrl }, duration, buildDefaultDisplay());
|
|
469
|
+
let dp1Item;
|
|
470
|
+
try {
|
|
471
|
+
dp1Item = itemBuilder.build();
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
return {
|
|
475
|
+
success: false,
|
|
476
|
+
error: error instanceof Error ? error.message : String(error),
|
|
477
|
+
};
|
|
456
478
|
}
|
|
457
|
-
applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
|
|
458
479
|
logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
|
|
459
480
|
title: token.name,
|
|
460
481
|
source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inline Ref Manifest construction for DP-1 playlist items.
|
|
4
|
+
*
|
|
5
|
+
* DP-1 Playlist Extension §3.6 lets an item carry a complete Ref Manifest
|
|
6
|
+
* inline instead of behind a `ref` URL. That matters here because ff-cli has
|
|
7
|
+
* nowhere to host a manifest document: it emits a playlist file and hands it
|
|
8
|
+
* to an FF1 device. Before dp1-js 2.3.0 there was no item-level home for the
|
|
9
|
+
* artwork description, artist, or still image the indexer already returns, so
|
|
10
|
+
* `nft-indexer.js` discarded all three. Inline manifests give them a home.
|
|
11
|
+
*
|
|
12
|
+
* This module is a leaf on purpose: it imports only `dp1-js` and the logger,
|
|
13
|
+
* never another `src/utilities/` module. `nft-indexer.js` already requires
|
|
14
|
+
* `playlist-builder.js`, so putting this logic in either of them would make
|
|
15
|
+
* the require graph a question that has to be re-answered on every edit.
|
|
16
|
+
*
|
|
17
|
+
* Integrity note: an inline manifest is covered by the playlist signature, so
|
|
18
|
+
* unlike a ref-fetched manifest it carries no `refHash`. Everything written
|
|
19
|
+
* here therefore ends up inside a signed document — which is why the mapping
|
|
20
|
+
* below refuses to synthesize fields the CLI cannot actually substantiate.
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.buildInlineManifestForToken = buildInlineManifestForToken;
|
|
57
|
+
const node_crypto_1 = require("node:crypto");
|
|
58
|
+
const dp1_js_1 = require("dp1-js");
|
|
59
|
+
const logger = __importStar(require("../logger"));
|
|
60
|
+
/**
|
|
61
|
+
* DP-1 version this CLI emits. RefManifestBuilder falls back to "0.1.0" when
|
|
62
|
+
* `refVersion` is unset, which would contradict the `dpVersion: 1.1.0`
|
|
63
|
+
* envelope playlist-builder.js builds around these items.
|
|
64
|
+
*/
|
|
65
|
+
const REF_VERSION = '1.1.0';
|
|
66
|
+
/**
|
|
67
|
+
* The Feral File indexer exposes no language tag on `display`, and the schema
|
|
68
|
+
* requires a locale. Every string we map comes from that one untagged source,
|
|
69
|
+
* so "en" is an assumption, not an observation. If the indexer ever returns a
|
|
70
|
+
* language, thread it through rather than widening this constant.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_LOCALE = 'en';
|
|
73
|
+
/**
|
|
74
|
+
* One CLI invocation stamps one manifest timestamp.
|
|
75
|
+
*
|
|
76
|
+
* Freezing at module load keeps a 500-token `build` from writing 500 different
|
|
77
|
+
* clocks across items of the same playlist, and keeps two runs byte-diffable
|
|
78
|
+
* except for this single value. The playlist envelope's own `created` already
|
|
79
|
+
* varies the same way (playlist-builder.js pins it only under the test-only
|
|
80
|
+
* deterministicMode). This assumes a short-lived process: do not reuse this
|
|
81
|
+
* module from a daemon without threading `created` in explicitly.
|
|
82
|
+
*/
|
|
83
|
+
const MANIFEST_CREATED_AT = new Date().toISOString();
|
|
84
|
+
/** Namespace prefix keeping manifest ids from aliasing PlaylistItem ids. */
|
|
85
|
+
const ID_NAMESPACE = 'dp1:ref-manifest:v1';
|
|
86
|
+
/** uuidFromSeed formats a sha256 digest UUID-style, mirroring nft-indexer.js. */
|
|
87
|
+
function uuidFromSeed(seed) {
|
|
88
|
+
const hash = (0, node_crypto_1.createHash)('sha256').update(seed).digest('hex');
|
|
89
|
+
return [
|
|
90
|
+
hash.slice(0, 8),
|
|
91
|
+
hash.slice(8, 12),
|
|
92
|
+
hash.slice(12, 16),
|
|
93
|
+
hash.slice(16, 20),
|
|
94
|
+
hash.slice(20, 32),
|
|
95
|
+
].join('-');
|
|
96
|
+
}
|
|
97
|
+
/** text trims a possibly-absent value down to a non-empty string. */
|
|
98
|
+
function text(value) {
|
|
99
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* resolveStillUri picks the still image that belongs in `metadata.thumbnails`.
|
|
103
|
+
*
|
|
104
|
+
* Returns an empty string unless the candidate is genuinely additional
|
|
105
|
+
* information. Three rejections, each for a distinct reason:
|
|
106
|
+
*
|
|
107
|
+
* - equal to `sourceUrl`: for a static token `image.url` IS the source
|
|
108
|
+
* (see getBestMediaUrl in nft-indexer.js), so a thumbnail identical to it
|
|
109
|
+
* is pure duplication inside a signed payload;
|
|
110
|
+
* - `data:`: both item factories already refuse data-URI sources, and
|
|
111
|
+
* inlining base64 into every playlist would dwarf the playlist itself;
|
|
112
|
+
* - anything not http(s): `Thumbnail.uri` carries `format: "uri"`, and an
|
|
113
|
+
* `ipfs://` value is not resolvable by the FF1 display path today.
|
|
114
|
+
*/
|
|
115
|
+
function resolveStillUri(token, sourceUrl) {
|
|
116
|
+
const image = token.image;
|
|
117
|
+
const candidate = typeof image === 'string' ? text(image) : text(image?.thumbnail) || text(image?.url);
|
|
118
|
+
if (!candidate || candidate === sourceUrl) {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
if (!candidate.startsWith('http://') && !candidate.startsWith('https://')) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* buildInlineManifestForToken builds the Ref Manifest for one playlist item,
|
|
128
|
+
* or returns undefined when the token carries nothing worth carrying.
|
|
129
|
+
*
|
|
130
|
+
* Emission threshold: at least one of description, artists, or thumbnails must
|
|
131
|
+
* survive normalization. `title` deliberately does NOT count — it is already a
|
|
132
|
+
* first-class PlaylistItem field, so a title-only manifest adds payload to
|
|
133
|
+
* every FF1 transfer and every signed envelope while telling a consumer
|
|
134
|
+
* nothing new. The threshold is expressed here, once, so the item factories
|
|
135
|
+
* stay a two-line call and cannot drift apart.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately not mapped, each for a reason that should survive future edits:
|
|
138
|
+
* - `creditLine`: the schema calls it copyright and credit information. The
|
|
139
|
+
* CLI has no such string; deriving one from `token.owner` would assert a
|
|
140
|
+
* rights claim it has no basis for, inside a signed document.
|
|
141
|
+
* - `tags`: `metadata.attributes` is hard-coded to [] upstream, and NFT
|
|
142
|
+
* traits are key/value pairs — flattening them yields garbage like
|
|
143
|
+
* ["Blue", "Hat"].
|
|
144
|
+
* - `collection`: no Metadata field accepts it, and upstream computes its
|
|
145
|
+
* name by splitting the token name on '#' — string surgery, not identity.
|
|
146
|
+
* - `owner`: not the artist, not credit, and stale the moment the token
|
|
147
|
+
* trades.
|
|
148
|
+
* - `controls`: display preferences already live on `item.display`, written
|
|
149
|
+
* from the DP-1 §4.1 timing heuristics. Duplicating them here would create
|
|
150
|
+
* two sources of truth with no rule for which wins.
|
|
151
|
+
* - `i18n`: no localized strings exist anywhere in this pipeline.
|
|
152
|
+
*
|
|
153
|
+
* Never throws. A manifest is decoration; nft-indexer.js turns a builder throw
|
|
154
|
+
* into `{ success: false }`, which the build pipeline counts as a dropped
|
|
155
|
+
* token — so a malformed thumbnail must not silently shorten a user's
|
|
156
|
+
* playlist.
|
|
157
|
+
*
|
|
158
|
+
* @param token - Token in the internal standard format
|
|
159
|
+
* @param opts - Resolved item source URL, and an optional created override
|
|
160
|
+
* @returns A schema-valid RefManifest, or undefined when below the threshold
|
|
161
|
+
*/
|
|
162
|
+
function buildInlineManifestForToken(token, opts = {}) {
|
|
163
|
+
if (!token || typeof token !== 'object') {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const sourceUrl = text(opts.sourceUrl);
|
|
167
|
+
const title = text(token.name);
|
|
168
|
+
const description = text(token.description);
|
|
169
|
+
const artistName = text(token.metadata?.artistName);
|
|
170
|
+
const stillUri = resolveStillUri(token, sourceUrl);
|
|
171
|
+
if (!description && !artistName && !stillUri) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const metadata = new dp1_js_1.MetadataBuilder();
|
|
176
|
+
if (title) {
|
|
177
|
+
metadata.title(title);
|
|
178
|
+
}
|
|
179
|
+
if (description) {
|
|
180
|
+
metadata.description(description);
|
|
181
|
+
}
|
|
182
|
+
if (artistName) {
|
|
183
|
+
metadata.addArtist({ name: artistName });
|
|
184
|
+
}
|
|
185
|
+
if (stillUri) {
|
|
186
|
+
// A plain literal, not ThumbnailBuilder: `w`/`h` became optional in
|
|
187
|
+
// dp1-js 2.3.0 and we never know the intrinsic pixel dimensions, so the
|
|
188
|
+
// emitted shape must be exactly { uri } with no undefined-valued keys.
|
|
189
|
+
metadata.addThumbnail('default', { uri: stillUri });
|
|
190
|
+
}
|
|
191
|
+
return new dp1_js_1.RefManifestBuilder()
|
|
192
|
+
.refVersion(REF_VERSION)
|
|
193
|
+
.id(manifestId(token, sourceUrl))
|
|
194
|
+
.created(opts.created ?? MANIFEST_CREATED_AT)
|
|
195
|
+
.locale(DEFAULT_LOCALE)
|
|
196
|
+
.metadata(metadata)
|
|
197
|
+
.build();
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
logger.debug('[Ref Manifest] Skipping inline manifest:', {
|
|
201
|
+
contractAddress: token.contractAddress,
|
|
202
|
+
tokenId: token.tokenId,
|
|
203
|
+
error: error instanceof Error ? error.message : String(error),
|
|
204
|
+
});
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* manifestId derives a stable id from token coordinates.
|
|
210
|
+
*
|
|
211
|
+
* The manifest describes the artwork, not one playlist entry, so the id must
|
|
212
|
+
* be identical across runs and across playlists — it is a cache key. The
|
|
213
|
+
* namespace prefix is load-bearing: nft-indexer.js derives the PlaylistItem id
|
|
214
|
+
* from the bare `${contract}-${tokenId}` string, and a consumer keying a
|
|
215
|
+
* manifest cache must never alias a playlist-item cache.
|
|
216
|
+
*/
|
|
217
|
+
function manifestId(token, sourceUrl) {
|
|
218
|
+
const contract = text(token.contractAddress);
|
|
219
|
+
const tokenId = text(String(token.tokenId ?? ''));
|
|
220
|
+
if (contract && tokenId) {
|
|
221
|
+
return uuidFromSeed(`${ID_NAMESPACE}:${text(token.chain)}:${contract}:${tokenId}`);
|
|
222
|
+
}
|
|
223
|
+
// Off-indexer tokens (caller-supplied objects) have no coordinates; the
|
|
224
|
+
// source URL is the only stable identity available.
|
|
225
|
+
return uuidFromSeed(`${ID_NAMESPACE}:src:${sourceUrl}`);
|
|
226
|
+
}
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -254,6 +254,10 @@ npm run dev -- play signed.json -d "Living Room Display"
|
|
|
254
254
|
# Play a hosted DP-1 playlist
|
|
255
255
|
npm run dev -- play "https://cdn.example.com/playlist.json"
|
|
256
256
|
|
|
257
|
+
# Play a playlist straight from a DP-1 feed server (the URL the feed hands out)
|
|
258
|
+
# Wallet-signed (eip191) documents need --skip-verify until #103 lands
|
|
259
|
+
npm run dev -- play "https://feed.feralfile.com/api/v1/playlists/<slug>" --skip-verify
|
|
260
|
+
|
|
257
261
|
# Play a media URL directly
|
|
258
262
|
npm run dev -- play "https://example.com/video.mp4"
|
|
259
263
|
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -105,7 +105,7 @@ Based on the code today, the CLI is responsible for:
|
|
|
105
105
|
- resolving marketplace URLs, on-chain coordinates, and wallet addresses into playlists (`find`)
|
|
106
106
|
- running feed fetches, address queries, contract-based NFT queries, domain resolution, playlist building, verification, publishing, and playback
|
|
107
107
|
- supporting a deterministic build path from structured JSON (`build`)
|
|
108
|
-
- building DP-1 playlist envelopes from NFT metadata or direct media URLs
|
|
108
|
+
- building DP-1 playlist envelopes from NFT metadata or direct media URLs via `dp1-js` document and leaf builders (so constructed playlists stay schema-conformant), including inline Ref Manifests on items that carry indexer metadata
|
|
109
109
|
- validating and verifying playlist structure and signatures
|
|
110
110
|
- signing playlists when a private key is configured
|
|
111
111
|
- publishing validated playlists to configured feed servers
|
|
@@ -174,6 +174,29 @@ Today the CLI groups into these workflow areas:
|
|
|
174
174
|
|
|
175
175
|
- `ssh enable|disable`
|
|
176
176
|
|
|
177
|
+
### Inline Ref Manifests
|
|
178
|
+
|
|
179
|
+
Playlist items carry a full Ref Manifest inline (DP-1 Playlist Extension §3.6)
|
|
180
|
+
rather than behind a `ref` URL, because the CLI emits a playlist file and has
|
|
181
|
+
nowhere to host a manifest document. Behavior worth knowing downstream:
|
|
182
|
+
|
|
183
|
+
- An item gets a manifest whenever the indexer supplies a description, an
|
|
184
|
+
artist, or a still image distinct from the item source. A token carrying only
|
|
185
|
+
a title gets none — the item already has a `title` field, so a title-only
|
|
186
|
+
manifest would add payload to every device transfer and signed envelope
|
|
187
|
+
without adding information.
|
|
188
|
+
- Manifest ids are derived from the token's chain, contract, and token id, so
|
|
189
|
+
they are stable across runs and across playlists — that stable `id` is what
|
|
190
|
+
identifies the manifest. `created` is frozen once per CLI invocation, so every
|
|
191
|
+
item in one build shares a single timestamp; it moves between builds, which is
|
|
192
|
+
expected (see "What determinism does not mean here").
|
|
193
|
+
- Items carry no `ref`. `ref` is a URI to an externally hosted Ref Manifest,
|
|
194
|
+
and requires `refHash` over HTTPS; a locally built playlist has nowhere to
|
|
195
|
+
host one, which is the case §3.6 exists to serve. A still image distinct from
|
|
196
|
+
the source is carried at `inlineManifest.metadata.thumbnails.default.uri`.
|
|
197
|
+
- An inline manifest has no `refHash`; its integrity comes from the playlist
|
|
198
|
+
signature.
|
|
199
|
+
|
|
177
200
|
## Deterministic-first behavior
|
|
178
201
|
|
|
179
202
|
The CLI is deterministic end to end. There is no natural-language interface; any natural-language layer lives in the user's coding agent, which drives these commands:
|
|
@@ -182,6 +205,59 @@ The CLI is deterministic end to end. There is no natural-language interface; any
|
|
|
182
205
|
- utilities perform the real work and are the source of truth for output correctness
|
|
183
206
|
- invalid or malformed outputs fail validation rather than being passed through
|
|
184
207
|
|
|
208
|
+
### What determinism does not mean here
|
|
209
|
+
|
|
210
|
+
It does **not** mean two builds of the same input produce identical bytes or an
|
|
211
|
+
identical signature. Every citation below is from
|
|
212
|
+
[`display-protocol/dp1`](https://github.com/display-protocol/dp1) at core
|
|
213
|
+
v1.1.0, so a reader can check each claim rather than take this section's word
|
|
214
|
+
for it.
|
|
215
|
+
|
|
216
|
+
**Timestamps are wall-clock, by definition.** The schemas define both creation
|
|
217
|
+
fields as the moment the document was made:
|
|
218
|
+
|
|
219
|
+
- `core/v1.1.0/schemas/playlist.json` — `created`: *"ISO 8601 timestamp when the
|
|
220
|
+
playlist was created"*
|
|
221
|
+
- `core/v1.1.0/schemas/ref-manifest.json` — `created`: *"RFC3339 timestamp when
|
|
222
|
+
the manifest was created"*
|
|
223
|
+
|
|
224
|
+
Building the same playlist twice creates two documents at two different times,
|
|
225
|
+
so those fields legitimately differ.
|
|
226
|
+
|
|
227
|
+
**Different bytes therefore mean a different signature, by construction.**
|
|
228
|
+
`core/v1.1.0/spec.md` §7: *"Each signature is computed over the canonical form
|
|
229
|
+
of the entire playlist (excluding the `signature` and `signatures` fields
|
|
230
|
+
themselves)"*, where *"Canonical form ≡ JSON Canonicalization Scheme (JCS), RFC
|
|
231
|
+
8785"*. A differing `created` changes the canonical bytes, which changes the
|
|
232
|
+
payload hash, which changes the signature. That is the signature reporting what
|
|
233
|
+
actually happened — not a defect to engineer away.
|
|
234
|
+
|
|
235
|
+
**Identity is carried by `id`, not by bytes.**
|
|
236
|
+
`core/v1.1.0/schemas/ref-manifest.json` describes `id` as *"Unique identifier
|
|
237
|
+
for the manifest (for caching)"*, echoed in the envelope example at
|
|
238
|
+
`core/v1.1.0/ref-manifest.md` §3: `"id": "ref-7c3d", // unique identifier (for
|
|
239
|
+
caching)`. Two manifests sharing an `id` are the same manifest. This CLI derives
|
|
240
|
+
manifest ids from token coordinates precisely so identity stays stable while
|
|
241
|
+
timestamps move.
|
|
242
|
+
|
|
243
|
+
**DP-1 states no reproducible-build requirement.** Searching `core/v1.1.0/spec.md`
|
|
244
|
+
and `core/v1.1.0/ref-manifest.md` for reproducibility language turns up the word
|
|
245
|
+
"deterministic" in exactly two places, neither about build-time byte stability:
|
|
246
|
+
|
|
247
|
+
- `spec.md` §5, *"Deterministic Reproduction (`repro`)"* — reproducing the
|
|
248
|
+
**rendering** of code-based artwork, via `engineVersion`, `seed`,
|
|
249
|
+
`assetsSHA256`, and `frameHash`.
|
|
250
|
+
- `ref-manifest.md` §2 Goals, *"Deterministic merging: predictable behavior
|
|
251
|
+
across devices and players"* — the **merge order** in §7 (player defaults →
|
|
252
|
+
playlist defaults → `ref.controls` → runtime overrides).
|
|
253
|
+
|
|
254
|
+
Neither imposes byte-for-byte stability across builds, and no schema field
|
|
255
|
+
requires `created` to be immutable across regenerations of a document.
|
|
256
|
+
|
|
257
|
+
`buildDP1Playlist` accepts `deterministicMode` with `fixedTimestamp` and
|
|
258
|
+
`fixedId`. Those exist to pin the envelope for tests and are not used by any
|
|
259
|
+
command.
|
|
260
|
+
|
|
185
261
|
## Trust, protocol, and rights assumptions
|
|
186
262
|
|
|
187
263
|
Important constraints from the broader FF system:
|
package/docs/README.md
CHANGED
|
@@ -12,14 +12,6 @@ npm i -g @feralfile/cli
|
|
|
12
12
|
|
|
13
13
|
`npm` and `npx` require **Node.js 22 or newer** (see `package.json` `engines`). When a release raises the Node floor, that is a **breaking** change for Node 18/20 users; the GitHub Release for that version should say so explicitly (see `./RELEASING.md` for maintainer guidance).
|
|
14
14
|
|
|
15
|
-
## Install (curl)
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
curl -fsSL https://feralfile.com/ff-cli-install | bash
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Installs a prebuilt bundle for macOS/Linux. Node.js 22 or newer must still be available in `PATH`; the bundle avoids a global npm install, not the Node runtime requirement.
|
|
22
|
-
|
|
23
15
|
## Configure
|
|
24
16
|
|
|
25
17
|
```bash
|
|
@@ -302,5 +294,5 @@ Setup preserves existing devices when adding new ones. See selection rules and e
|
|
|
302
294
|
## Links
|
|
303
295
|
|
|
304
296
|
- Examples: `./EXAMPLES.md`
|
|
305
|
-
-
|
|
297
|
+
- Releasing: `./RELEASING.md`
|
|
306
298
|
- DP1 spec: `https://github.com/display-protocol/dp1`
|
package/docs/RELEASING.md
CHANGED
|
@@ -7,7 +7,7 @@ How releases happen, how to cut one, and what to do when things go sideways.
|
|
|
7
7
|
1. **Bump `package.json` version** on `main` (or via a `chore: bump version to X.Y.Z` PR — the project has used both patterns).
|
|
8
8
|
2. **Push to `main`** so the tag created in step 3 points at the bump commit.
|
|
9
9
|
3. **Create a GitHub Release** with the tag equal to the version (e.g. tag `1.2.0` → `"version": "1.2.0"`). Use `gh release create 1.2.0 --title "v1.2.0" --notes "..."` or the web UI.
|
|
10
|
-
4. **The `release.yml` workflow fires automatically**, verifies tag == `package.json` version, runs CI, publishes to npm via OIDC,
|
|
10
|
+
4. **The `release.yml` workflow fires automatically**, verifies tag == `package.json` version, runs CI, publishes to npm via OIDC, and verifies the package landed on npm.
|
|
11
11
|
|
|
12
12
|
That's it. No tokens, no manual `npm publish`. The whole thing takes ~4 minutes end-to-end.
|
|
13
13
|
|
|
@@ -45,35 +45,12 @@ You will probably never do this again for `@feralfile/cli`, but if you ever publ
|
|
|
45
45
|
## GitHub Actions workflows
|
|
46
46
|
|
|
47
47
|
- **`ci.yml`** — formatting, lint, tests, build on Node 22 across ubuntu/macos/windows. Called by `release.yml` and runs on PRs / pushes to `main`.
|
|
48
|
-
- **`
|
|
49
|
-
- **`release.yml`** — orchestrates a release. Verifies version, runs CI, publishes to npm (Node 24 + OIDC + provenance), builds binaries, uploads release assets, verifies the package and assets are live. Triggers on published GitHub Releases or manual dispatch (beta only).
|
|
48
|
+
- **`release.yml`** — orchestrates a release. Verifies version, runs CI, publishes to npm (Node 24 + OIDC + provenance), and verifies the package is live. Triggers on published GitHub Releases or manual dispatch (beta only).
|
|
50
49
|
- **`codeql.yml`**, **`dependency-review.yml`** — security scanning.
|
|
51
50
|
|
|
52
|
-
## Binary release assets
|
|
53
|
-
|
|
54
|
-
The curl installer (`https://feralfile.com/ff-cli-install`) downloads prebuilt binaries from GitHub Releases. The release workflow builds and uploads them automatically. To build one locally:
|
|
55
|
-
|
|
56
|
-
**macOS / Linux:**
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
./scripts/release/build-asset.sh
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
**Windows (PowerShell):**
|
|
63
|
-
|
|
64
|
-
```powershell
|
|
65
|
-
.\scripts\release\build-asset-windows.ps1
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Produces (names vary by OS/arch):
|
|
69
|
-
|
|
70
|
-
- `release/ff-cli-darwin-arm64.tar.gz` (and `.sha256`) on macOS
|
|
71
|
-
- `release/ff-cli-linux-x64.tar.gz` (and `.sha256`) on Linux
|
|
72
|
-
- `release/ff-cli-windows-x64.zip` (and `.sha256`) on Windows
|
|
73
|
-
|
|
74
51
|
## Release notes and breaking changes
|
|
75
52
|
|
|
76
|
-
GitHub Release text (and any user-facing summary you publish with the version) should state compatibility changes in plain language. **Do not rely on `package.json` `engines` alone**; npm
|
|
53
|
+
GitHub Release text (and any user-facing summary you publish with the version) should state compatibility changes in plain language. **Do not rely on `package.json` `engines` alone**; npm surfaces it inconsistently, and operators skim release notes first.
|
|
77
54
|
|
|
78
55
|
### Node.js engine floor (breaking)
|
|
79
56
|
|
|
@@ -89,22 +66,6 @@ GitHub Release text (and any user-facing summary you publish with the version) s
|
|
|
89
66
|
|
|
90
67
|
Later releases only need to repeat this block if the engine floor changes again.
|
|
91
68
|
|
|
92
|
-
## Installer redirect
|
|
93
|
-
|
|
94
|
-
`https://feralfile.com/ff-cli-install` should redirect to:
|
|
95
|
-
|
|
96
|
-
```
|
|
97
|
-
https://raw.githubusercontent.com/feral-file/ff-cli/main/scripts/install.sh
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
The installer script then fetches the release assets from GitHub Releases.
|
|
101
|
-
|
|
102
|
-
## Environment overrides
|
|
103
|
-
|
|
104
|
-
- `FF_CLI_VERSION`: overrides the version label in logs
|
|
105
|
-
- `FF_CLI_NODE_VERSION`: Reserved in script headers for future use; current CI, npm `engines`, and release wrappers assume **Node.js 22+** (required by `dp1-js`).
|
|
106
|
-
- `FF_CLI_OUTPUT_DIR`: output directory (default: `./release`)
|
|
107
|
-
|
|
108
69
|
## Troubleshooting
|
|
109
70
|
|
|
110
71
|
### `404 Not Found - PUT https://registry.npmjs.org/@feralfile%2fcli`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"author": "",
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@napi-rs/keyring": "1.3.0",
|
|
62
61
|
"@feralfile/source-resolver": "^1.0.0",
|
|
62
|
+
"@napi-rs/keyring": "1.3.0",
|
|
63
63
|
"axios": "^1.6.2",
|
|
64
64
|
"bonjour-service": "^1.3.0",
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
67
67
|
"dotenv": "^16.3.1",
|
|
68
|
-
"dp1-js": "2.
|
|
68
|
+
"dp1-js": "2.3.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
71
|
"viem": "^2.38.2"
|