@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 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 is the recommended path and the most reliable for the agent workflow — it installs a complete package (including `config.json.example` and the bundled `dp1-js`). Requires Node.js 22+.
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/build.yml` builds release assets when called by release automation or manually dispatched.
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.2.3",
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.0.0",
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
- ...item,
358
- created: item.created || new Date().toISOString(), // Ensure created field exists
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 GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql';
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 { applyItemTiming } = require('./playlist-builder');
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 DP1 item structure (strict DP1 v1.0.0 compliance)
438
- const dp1Item = {
439
- id: itemId,
440
- source: sourceUrl,
441
- license: 'open',
442
- created: new Date().toISOString(),
443
- provenance: {
444
- type: 'onChain',
445
- contract: {
446
- chain: chainMap[token.chain.toLowerCase()] || 'other',
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
- dp1Item.title = token.name;
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
- * 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
  };
@@ -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
 
@@ -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
- - Release assets: `./RELEASING.md`
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, builds binary assets, uploads them to the release, and verifies the package landed on npm.
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
- - **`build.yml`** — builds prebuilt binaries (macOS/Linux/Windows) and uploads them as workflow artifacts. Triggered manually or from `release.yml`.
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 and installers surface it inconsistently, and operators skim release notes first.
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.2.3",
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.0.0",
68
+ "dp1-js": "2.3.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
71
  "viem": "^2.38.2"