@feralfile/cli 2.2.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -11
- package/dist/index.js +2 -0
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/feed-fetcher.js +34 -4
- package/dist/src/utilities/nft-indexer.js +50 -20
- package/dist/src/utilities/playlist-builder.js +187 -209
- package/dist/src/utilities/ref-manifest.js +226 -0
- package/docs/EXAMPLES.md +42 -0
- package/docs/PROJECT_SPEC.md +199 -1
- package/docs/README.md +39 -9
- package/docs/RELEASING.md +3 -42
- package/package.json +3 -3
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fills in missing inline Ref Manifests on an existing DP-1 playlist.
|
|
4
|
+
*
|
|
5
|
+
* `find` and `build` attach an `inlineManifest` to every item they create, so
|
|
6
|
+
* playlists this CLI authors end to end are already labelled. Playlists that
|
|
7
|
+
* arrive any other way are not: items copied out of another playlist, items
|
|
8
|
+
* hand-written against a known contract, items inherited from a document
|
|
9
|
+
* predating §3.6. Those carry a `source` and a `provenance` block and nothing
|
|
10
|
+
* else, and there was no way to repair them short of editing JSON by hand.
|
|
11
|
+
*
|
|
12
|
+
* What the gap costs is visible on a device. The FF1 tombstone resolves its
|
|
13
|
+
* label through `ref` -> `inlineManifest` -> `item.metadata` and falls back to
|
|
14
|
+
* the item title, so an unlabelled item shows a title with no artist line. The
|
|
15
|
+
* app's grid is worse: it can rasterize a thumbnail from a direct image, video,
|
|
16
|
+
* or SVG source, but not from a live HTML work, so every generative item in an
|
|
17
|
+
* unlabelled playlist renders as an empty tile. A playlist of computational art
|
|
18
|
+
* is mostly live HTML, which is exactly the case that looks broken.
|
|
19
|
+
*
|
|
20
|
+
* Enrichment is additive and narrow on purpose. It writes `inlineManifest` and
|
|
21
|
+
* nothing else: `source`, `duration`, `id`, and `display` are the curator's
|
|
22
|
+
* decisions and are left exactly as found, even when the indexer disagrees. An
|
|
23
|
+
* item the indexer cannot resolve is reported, never guessed at — a fabricated
|
|
24
|
+
* artist line in a signed document is worse than a missing one.
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.enrichPlaylistManifests = enrichPlaylistManifests;
|
|
61
|
+
const node_crypto_1 = require("node:crypto");
|
|
62
|
+
const logger = __importStar(require("../logger"));
|
|
63
|
+
/**
|
|
64
|
+
* CHAIN_ALIASES translates a DP-1 chain name into the indexer's, outbound.
|
|
65
|
+
*
|
|
66
|
+
* DP-1 names the EVM family `evm` (core spec §6: "evm" | "tezos" | "bitmark" |
|
|
67
|
+
* "other"), because the protocol describes a class of chains. The FF indexer
|
|
68
|
+
* names the same thing `ethereum`, because it queries a specific one. Without
|
|
69
|
+
* this every EVM item comes back unresolved, and the failure looks like a
|
|
70
|
+
* missing artwork rather than a vocabulary mismatch.
|
|
71
|
+
*
|
|
72
|
+
* Unlisted values pass through so the indexer, not this table, decides what it
|
|
73
|
+
* supports.
|
|
74
|
+
*/
|
|
75
|
+
const CHAIN_ALIASES = {
|
|
76
|
+
evm: 'ethereum',
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* `evm` names a family, and this maps it to one member of that family.
|
|
80
|
+
*
|
|
81
|
+
* DP-1 core §6 offers "evm" | "tezos" | "bitmark" | "other" and carries no
|
|
82
|
+
* network identity, while the indexer maps Ethereum, Polygon, Arbitrum,
|
|
83
|
+
* Optimism, Base, and Zora all back to `evm`. So a DP-1 coordinate cannot say
|
|
84
|
+
* which EVM network it means, and neither can the response.
|
|
85
|
+
*
|
|
86
|
+
* Ethereum is chosen because it is the only EVM network this client can reach:
|
|
87
|
+
* buildTokenCID maps ethereum to eip155:1 and tezos to tezos:mainnet, and
|
|
88
|
+
* throws for everything else. A --chain flag would not widen that — it would
|
|
89
|
+
* only move the throw. The residual risk is narrow and real: an L2 work whose
|
|
90
|
+
* address and token id also exist on Ethereum would be enriched with the
|
|
91
|
+
* Ethereum work's metadata, and nothing in the response could reveal it.
|
|
92
|
+
*
|
|
93
|
+
* The count is surfaced so the assumption is stated at the point of use rather
|
|
94
|
+
* than buried here. Widening it belongs upstream, in the indexer client.
|
|
95
|
+
*/
|
|
96
|
+
const AMBIGUOUS_CHAIN = 'evm';
|
|
97
|
+
/**
|
|
98
|
+
* One CLI invocation stamps one manifest timestamp, matching ref-manifest.ts:
|
|
99
|
+
* a run that synthesizes several manifests should not write several clocks.
|
|
100
|
+
*/
|
|
101
|
+
const MANIFEST_CREATED = new Date().toISOString();
|
|
102
|
+
/**
|
|
103
|
+
* canonicalChain collapses both vocabularies onto one name so a coordinate
|
|
104
|
+
* sent to the indexer and a coordinate read back off its response compare
|
|
105
|
+
* equal. The direction is deliberately the reverse of CHAIN_ALIASES: this one
|
|
106
|
+
* is for matching, that one is for querying.
|
|
107
|
+
*/
|
|
108
|
+
function canonicalChain(chain) {
|
|
109
|
+
const lower = chain.trim().toLowerCase();
|
|
110
|
+
return lower === 'ethereum' ? 'evm' : lower;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* coordinateKeyOf builds the correlation key for a provenance block, or null
|
|
114
|
+
* when the block cannot identify a single work.
|
|
115
|
+
*
|
|
116
|
+
* The address is lowercased because the indexer echoes it checksummed while
|
|
117
|
+
* callers typically write it lowercase, and the two must still match.
|
|
118
|
+
*/
|
|
119
|
+
function coordinateKeyOf(provenance) {
|
|
120
|
+
const contract = provenance?.contract;
|
|
121
|
+
if (!contract) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const { chain, address, tokenId } = contract;
|
|
125
|
+
if (!chain || !address || tokenId === undefined || tokenId === null) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const token = String(tokenId).trim();
|
|
129
|
+
if (token.length === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* describeItem names an item for operator output, preferring its title.
|
|
136
|
+
*/
|
|
137
|
+
function describeItem(item, index) {
|
|
138
|
+
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
|
139
|
+
return title.length > 0 ? title : `item ${index + 1}`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* coordinateFor extracts a lookup key from an item, or null when the item
|
|
143
|
+
* carries no usable on-chain coordinate.
|
|
144
|
+
*
|
|
145
|
+
* All three parts are required. A contract with no token id identifies a
|
|
146
|
+
* collection rather than a work, and resolving that would attach some other
|
|
147
|
+
* item's manifest to this one.
|
|
148
|
+
*/
|
|
149
|
+
function coordinateFor(item) {
|
|
150
|
+
const contract = item.provenance?.contract;
|
|
151
|
+
if (!contract) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const { chain, address, tokenId } = contract;
|
|
155
|
+
if (!chain || !address || tokenId === undefined || tokenId === null) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const token = String(tokenId).trim();
|
|
159
|
+
if (token.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const normalized = chain.trim().toLowerCase();
|
|
163
|
+
return {
|
|
164
|
+
chain: CHAIN_ALIASES[normalized] ?? normalized,
|
|
165
|
+
contractAddress: address,
|
|
166
|
+
tokenId: token,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
|
|
171
|
+
*
|
|
172
|
+
* `resolveStillUri` blanks a still that equals the source it was given, on the
|
|
173
|
+
* reasonable ground that pointing a thumbnail at the artwork itself adds
|
|
174
|
+
* payload and no information. But the source it was given is the one the
|
|
175
|
+
* INDEXER chose, not the one the curator kept. For a static work the indexer
|
|
176
|
+
* commonly selects the still as the source, so the thumbnail is dropped —
|
|
177
|
+
* and when the curator's item points at a live HTML generator instead, the
|
|
178
|
+
* item ends up with no still at all. That is precisely the case this command
|
|
179
|
+
* exists to repair: a live work the app cannot rasterize, whose grid tile
|
|
180
|
+
* stays empty.
|
|
181
|
+
*
|
|
182
|
+
* Recovering it is the inverse of the suppression. When the indexer's source
|
|
183
|
+
* differs from the item's and is an http(s) URL, that source IS the still that
|
|
184
|
+
* was elided, so it becomes the thumbnail relative to the item's own source.
|
|
185
|
+
*/
|
|
186
|
+
function stillFromIndexerSource(item, resolved) {
|
|
187
|
+
const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
|
|
188
|
+
const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
|
|
189
|
+
if (!indexerSource || indexerSource === itemSource) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
// The indexer's source is not necessarily a still. getBestMediaUrl prefers
|
|
196
|
+
// display.animation_url and media assets over display.image_url, so it is
|
|
197
|
+
// frequently a live HTML rendition — and writing one into the thumbnail slot
|
|
198
|
+
// is worse than leaving the slot empty: the grid still cannot rasterize it,
|
|
199
|
+
// and the item now claims a still it does not have.
|
|
200
|
+
//
|
|
201
|
+
// Only a URL that is demonstrably an image is used. The indexer exposes no
|
|
202
|
+
// separately identified still on the item this command receives, so the file
|
|
203
|
+
// extension is the evidence available; carrying a real image field through
|
|
204
|
+
// the batch result would be the better fix and belongs upstream.
|
|
205
|
+
return looksLikeImage(indexerSource) ? indexerSource : '';
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Media the app can draw a grid tile from, matching the types
|
|
209
|
+
* playlist-builder.js already recognizes. SVG and video belong here: the app
|
|
210
|
+
* rasterizes both, and excluding them left works with the empty tile this
|
|
211
|
+
* command exists to remove. HTML is deliberately absent — an unrasterizable
|
|
212
|
+
* page in the thumbnail slot is worse than an empty slot, because the item
|
|
213
|
+
* then claims a still it does not have.
|
|
214
|
+
*/
|
|
215
|
+
const RASTERIZABLE_EXTENSIONS = [
|
|
216
|
+
'.png',
|
|
217
|
+
'.jpg',
|
|
218
|
+
'.jpeg',
|
|
219
|
+
'.gif',
|
|
220
|
+
'.webp',
|
|
221
|
+
'.avif',
|
|
222
|
+
'.bmp',
|
|
223
|
+
'.svg',
|
|
224
|
+
'.mp4',
|
|
225
|
+
'.webm',
|
|
226
|
+
];
|
|
227
|
+
/**
|
|
228
|
+
* looksLikeImage tests a URL's path extension, ignoring query and fragment.
|
|
229
|
+
*/
|
|
230
|
+
function looksLikeImage(url) {
|
|
231
|
+
let pathname = url;
|
|
232
|
+
try {
|
|
233
|
+
pathname = new URL(url).pathname;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
pathname = url.split('?')[0].split('#')[0];
|
|
237
|
+
}
|
|
238
|
+
const lower = pathname.toLowerCase();
|
|
239
|
+
return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* hasThumbnail reports whether a manifest already carries a default still.
|
|
243
|
+
*/
|
|
244
|
+
function hasThumbnail(manifest) {
|
|
245
|
+
const thumbnails = manifest.metadata
|
|
246
|
+
?.thumbnails;
|
|
247
|
+
return typeof thumbnails?.default?.uri === 'string' && thumbnails.default.uri.length > 0;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* manifestFor returns the manifest to write onto an item, or undefined when
|
|
251
|
+
* the indexer resolved the token without building one.
|
|
252
|
+
*
|
|
253
|
+
* The curator's own title wins. `item.title` survives enrichment untouched,
|
|
254
|
+
* but the tombstone reads the manifest first, so attaching an indexer title
|
|
255
|
+
* that disagrees would change the displayed label while appearing to preserve
|
|
256
|
+
* it — the indexer writes "Pre-Process #0" where a curator wrote
|
|
257
|
+
* "Pre-Process". Everything else in the manifest (artist, description,
|
|
258
|
+
* thumbnails) is what enrichment exists to fetch and is taken as given.
|
|
259
|
+
*/
|
|
260
|
+
function manifestFor(item, resolved) {
|
|
261
|
+
const still = stillFromIndexerSource(item, resolved);
|
|
262
|
+
let manifest = resolved.inlineManifest;
|
|
263
|
+
let rekey = false;
|
|
264
|
+
if (manifest && still && !hasThumbnail(manifest)) {
|
|
265
|
+
manifest = {
|
|
266
|
+
...manifest,
|
|
267
|
+
metadata: {
|
|
268
|
+
...(manifest.metadata ?? {}),
|
|
269
|
+
thumbnails: { default: { uri: still } },
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
rekey = true;
|
|
273
|
+
}
|
|
274
|
+
else if (!manifest && still) {
|
|
275
|
+
// Nothing was emitted because the still was the only content the token
|
|
276
|
+
// had, and it had been suppressed. A thumbnail-only manifest is worth its
|
|
277
|
+
// payload in a way a title-only one is not: it is the difference between
|
|
278
|
+
// a grid tile and an empty square.
|
|
279
|
+
const synthesizedTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
280
|
+
manifest = {
|
|
281
|
+
refVersion: '1.1.0',
|
|
282
|
+
id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
|
|
283
|
+
created: MANIFEST_CREATED,
|
|
284
|
+
locale: 'en',
|
|
285
|
+
metadata: {
|
|
286
|
+
...(synthesizedTitle ? { title: synthesizedTitle } : {}),
|
|
287
|
+
thumbnails: { default: { uri: still } },
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (!manifest) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const metadata = manifest.metadata ?? {};
|
|
295
|
+
const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
|
|
296
|
+
const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
|
|
297
|
+
// The id is derived from the finished payload every time, not only when this
|
|
298
|
+
// command changed something. The indexer keys its id on the token
|
|
299
|
+
// coordinate, so every rendition of one artwork carries the same id whatever
|
|
300
|
+
// its content — and under --force the indexer may itself return a different
|
|
301
|
+
// artist or description under that unchanged id. Deriving from the payload
|
|
302
|
+
// is the only rule that holds in every case: identical content keeps one
|
|
303
|
+
// identity, differing content never shares one.
|
|
304
|
+
//
|
|
305
|
+
// The original id is folded in, so the result stays anchored to the token
|
|
306
|
+
// rather than floating free of it, and stays deterministic across runs.
|
|
307
|
+
//
|
|
308
|
+
// `rekey` is read only to record that a thumbnail was recovered; the
|
|
309
|
+
// derivation below covers it either way.
|
|
310
|
+
void rekey;
|
|
311
|
+
const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
|
|
312
|
+
return {
|
|
313
|
+
...manifest,
|
|
314
|
+
id: derivedManifestId(typeof manifest.id === 'string' ? manifest.id : '', stableStringify(finalMetadata)),
|
|
315
|
+
metadata: finalMetadata,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* stableStringify serializes a value with object keys in sorted order, so the
|
|
320
|
+
* same content hashes the same however it was assembled.
|
|
321
|
+
*/
|
|
322
|
+
function stableStringify(value) {
|
|
323
|
+
if (value === null || typeof value !== 'object') {
|
|
324
|
+
return JSON.stringify(value) ?? 'null';
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(value)) {
|
|
327
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
328
|
+
}
|
|
329
|
+
const entries = Object.entries(value)
|
|
330
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
331
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
332
|
+
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
|
333
|
+
return `{${entries.join(',')}}`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* derivedManifestId produces a stable id for a manifest whose title this
|
|
337
|
+
* command replaced, shaped as a UUID so it reads like every other manifest id.
|
|
338
|
+
*/
|
|
339
|
+
function derivedManifestId(originalId, title) {
|
|
340
|
+
const digest = (0, node_crypto_1.createHash)('sha256').update(`${originalId}\n${title}`).digest('hex');
|
|
341
|
+
return [
|
|
342
|
+
digest.slice(0, 8),
|
|
343
|
+
digest.slice(8, 12),
|
|
344
|
+
digest.slice(12, 16),
|
|
345
|
+
digest.slice(16, 20),
|
|
346
|
+
digest.slice(20, 32),
|
|
347
|
+
].join('-');
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* enrichPlaylistManifests attaches an inline Ref Manifest to every item that
|
|
351
|
+
* lacks one and can be resolved on chain.
|
|
352
|
+
*
|
|
353
|
+
* The playlist is mutated in place and returned; callers that need the
|
|
354
|
+
* original should read it twice. Items are looked up in one batch so the
|
|
355
|
+
* indexer sees a single burst rather than a request per item — warming
|
|
356
|
+
* previously-unseen tokens is the slow path, and it parallelizes internally.
|
|
357
|
+
*
|
|
358
|
+
* Signatures are dropped when anything changed, in both the DP-1 v1.1.0
|
|
359
|
+
* `signatures[]` form and the legacy flat `signature` this repo still verifies.
|
|
360
|
+
* A signature covers the document bytes, and an enriched document is a
|
|
361
|
+
* different document; leaving a stale envelope in place would produce a file
|
|
362
|
+
* that fails verification at the device with no explanation of why.
|
|
363
|
+
*/
|
|
364
|
+
async function enrichPlaylistManifests(playlist, lookup, options = {}) {
|
|
365
|
+
const items = Array.isArray(playlist.items) ? playlist.items : [];
|
|
366
|
+
const skipped = [];
|
|
367
|
+
const pending = [];
|
|
368
|
+
// One lookup per distinct artwork, not per item. getNFTTokenInfoBatch runs a
|
|
369
|
+
// lookup for each token it is handed, and a token the indexer has not seen
|
|
370
|
+
// triggers an indexing job and a poll that can take minutes. A playlist that
|
|
371
|
+
// deliberately repeats a work — the same piece at two points in a loop —
|
|
372
|
+
// would otherwise submit that job once per appearance.
|
|
373
|
+
const coordinates = new Map();
|
|
374
|
+
items.forEach((item, index) => {
|
|
375
|
+
// An item with a `ref` is already labelled, by a manifest this command
|
|
376
|
+
// cannot see. Ref outranks inlineManifest in the resolution order, so
|
|
377
|
+
// enriching one writes a manifest the device will ignore while still
|
|
378
|
+
// invalidating the signature: damage with no visible change.
|
|
379
|
+
//
|
|
380
|
+
// --force does not override this. Making the inline manifest win would
|
|
381
|
+
// mean deleting the curator's `ref` and `refHash`, which is a different
|
|
382
|
+
// and larger decision than filling in missing metadata — and one that
|
|
383
|
+
// silently discards the integrity hash the remote manifest is checked
|
|
384
|
+
// against. If that conversion is ever wanted it should be its own flag,
|
|
385
|
+
// named for what it does.
|
|
386
|
+
if (typeof item.ref === 'string' && item.ref.trim().length > 0) {
|
|
387
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'external-ref' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (item.inlineManifest && !options.force) {
|
|
391
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'already-labelled' });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const coordinate = coordinateFor(item);
|
|
395
|
+
const key = coordinateKeyOf(item.provenance);
|
|
396
|
+
if (!coordinate || !key) {
|
|
397
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'no-provenance' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// `evm` names a family and the response cannot narrow it, so enriching one
|
|
401
|
+
// on the assumption of Ethereum can attach a different artwork's artist,
|
|
402
|
+
// description, and still — into a document the curator then signs. The
|
|
403
|
+
// operator has to assert the network; the command will not assume it.
|
|
404
|
+
if (key.startsWith(`${AMBIGUOUS_CHAIN}:`) && !options.assumeEthereum) {
|
|
405
|
+
skipped.push({ index, title: describeItem(item, index), reason: 'ambiguous-chain' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
pending.push({ index, key });
|
|
409
|
+
if (!coordinates.has(key)) {
|
|
410
|
+
coordinates.set(key, coordinate);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
const assumedEthereum = [...coordinates.keys()].filter((key) => key.startsWith(`${AMBIGUOUS_CHAIN}:`)).length;
|
|
414
|
+
if (pending.length === 0) {
|
|
415
|
+
return { playlist, enriched: 0, skipped, signatureInvalidated: false, assumedEthereum: 0 };
|
|
416
|
+
}
|
|
417
|
+
const resolved = await lookup([...coordinates.values()], options.onProgress);
|
|
418
|
+
// Index what came back by its own coordinate. A response carrying no usable
|
|
419
|
+
// provenance cannot be attributed to any request, so it is dropped rather
|
|
420
|
+
// than guessed at; the affected item then reports as not-indexed, which is
|
|
421
|
+
// exactly what happened.
|
|
422
|
+
const byCoordinate = new Map();
|
|
423
|
+
for (const entry of resolved) {
|
|
424
|
+
const key = coordinateKeyOf(entry.provenance);
|
|
425
|
+
if (key && !byCoordinate.has(key)) {
|
|
426
|
+
byCoordinate.set(key, entry);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
let enriched = 0;
|
|
430
|
+
for (const entry of pending) {
|
|
431
|
+
const item = items[entry.index];
|
|
432
|
+
const match = byCoordinate.get(entry.key);
|
|
433
|
+
const manifest = match ? manifestFor(item, match) : undefined;
|
|
434
|
+
if (!manifest) {
|
|
435
|
+
// The indexer resolving a token and the indexer having anything worth
|
|
436
|
+
// attaching are different outcomes. buildInlineManifestForToken returns
|
|
437
|
+
// undefined when it has only a title, because a title-only manifest adds
|
|
438
|
+
// payload to every transfer without adding information. Reporting that
|
|
439
|
+
// as "the indexer returned nothing" would be false.
|
|
440
|
+
skipped.push({
|
|
441
|
+
index: entry.index,
|
|
442
|
+
title: describeItem(item, entry.index),
|
|
443
|
+
reason: match ? 'no-metadata' : 'not-indexed',
|
|
444
|
+
});
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
item.inlineManifest = manifest;
|
|
448
|
+
enriched += 1;
|
|
449
|
+
}
|
|
450
|
+
const hadSignature = playlist.signatures !== undefined || playlist.signature !== undefined;
|
|
451
|
+
const signatureInvalidated = enriched > 0 && hadSignature;
|
|
452
|
+
if (signatureInvalidated) {
|
|
453
|
+
delete playlist.signatures;
|
|
454
|
+
delete playlist.signature;
|
|
455
|
+
logger.debug('[Enrich] Dropped signatures: the enriched document must be re-signed.');
|
|
456
|
+
}
|
|
457
|
+
skipped.sort((a, b) => a.index - b.index);
|
|
458
|
+
return { playlist, enriched, skipped, signatureInvalidated, assumedEthereum };
|
|
459
|
+
}
|
|
@@ -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,24 @@
|
|
|
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
|
+
// The production endpoint is hardcoded, as PROJECT_SPEC states, and stays
|
|
8
|
+
// that way. INDEXER_API_URL is a test hook, not configuration: the
|
|
9
|
+
// find-command suite spawns the CLI as a child process, so the only way to
|
|
10
|
+
// point it at a local mock is through the environment.
|
|
11
|
+
//
|
|
12
|
+
// It is gated on NODE_ENV=test because index.ts loads dotenv at startup. An
|
|
13
|
+
// ungated read would mean a stray INDEXER_API_URL in someone's .env, or an
|
|
14
|
+
// inherited shell value, silently redirecting every token lookup and indexing
|
|
15
|
+
// mutation — a configuration surface nobody documented and no one would think
|
|
16
|
+
// to check. The gate keeps the hook usable without creating that surface.
|
|
17
|
+
const GRAPHQL_ENDPOINT = process.env.NODE_ENV === 'test' && process.env.INDEXER_API_URL
|
|
18
|
+
? process.env.INDEXER_API_URL
|
|
19
|
+
: 'https://indexer.feralfile.com/graphql';
|
|
7
20
|
const { fetchWithTimeout } = require('./http');
|
|
8
21
|
const logger = require('../logger');
|
|
9
|
-
const {
|
|
22
|
+
const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
|
|
23
|
+
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
10
24
|
// Polling configuration (in milliseconds)
|
|
11
25
|
const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
|
|
12
26
|
const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
|
|
@@ -355,6 +369,9 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
|
355
369
|
* Extracts source URL from indexer data with proper priority:
|
|
356
370
|
* animation_url > image.url, ensuring the best quality media is used.
|
|
357
371
|
*
|
|
372
|
+
* When the token carries description, artist, or a still image distinct from
|
|
373
|
+
* the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
|
|
374
|
+
*
|
|
358
375
|
* @param {Object} tokenData - Token data in standard format
|
|
359
376
|
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
360
377
|
* (video/audio play natural length per DP-1 §4.1)
|
|
@@ -434,27 +451,40 @@ function convertToDP1Item(tokenData, duration) {
|
|
|
434
451
|
tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
|
|
435
452
|
bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
|
|
436
453
|
};
|
|
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
|
-
};
|
|
454
|
+
// Build via dp1-js PlaylistItemBuilder so leaf blocks match DP-1 AJV schema.
|
|
455
|
+
const itemBuilder = new PlaylistItemBuilder()
|
|
456
|
+
.id(itemId)
|
|
457
|
+
.source(sourceUrl)
|
|
458
|
+
.license('open')
|
|
459
|
+
.provenance(new ProvenanceBuilder().type('onChain').contract(new ContractBuilder()
|
|
460
|
+
.chain(chainMap[token.chain.toLowerCase()] || 'other')
|
|
461
|
+
.standard(token.standard || detectTokenStandard(token.chain, token.contractAddress))
|
|
462
|
+
.address(token.contractAddress)
|
|
463
|
+
.tokenId(String(token.tokenId))));
|
|
453
464
|
// Add title if available (valid DP1 field)
|
|
454
465
|
if (token.name) {
|
|
455
|
-
|
|
466
|
+
itemBuilder.title(token.name);
|
|
467
|
+
}
|
|
468
|
+
// Carry description, artist, and still image inline (DP-1 Playlist Extension
|
|
469
|
+
// §3.6). These are the fields the indexer returns that have no PlaylistItem
|
|
470
|
+
// home; before inline manifests existed they were mapped and then dropped on
|
|
471
|
+
// the floor here. The helper returns undefined when the token has nothing
|
|
472
|
+
// beyond its title, so thin tokens stay thin.
|
|
473
|
+
const inlineManifest = buildInlineManifestForToken(token, { sourceUrl });
|
|
474
|
+
if (inlineManifest) {
|
|
475
|
+
itemBuilder.inlineManifest(inlineManifest);
|
|
476
|
+
}
|
|
477
|
+
applyTimingToItemBuilder(itemBuilder, { mimeType: token.image?.mimeType, sourceUrl }, duration, buildDefaultDisplay());
|
|
478
|
+
let dp1Item;
|
|
479
|
+
try {
|
|
480
|
+
dp1Item = itemBuilder.build();
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
return {
|
|
484
|
+
success: false,
|
|
485
|
+
error: error instanceof Error ? error.message : String(error),
|
|
486
|
+
};
|
|
456
487
|
}
|
|
457
|
-
applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
|
|
458
488
|
logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
|
|
459
489
|
title: token.name,
|
|
460
490
|
source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
|