@feralfile/cli 1.5.0 → 1.6.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findCommand = void 0;
40
+ exports.buildRasterMediaItems = buildRasterMediaItems;
40
41
  exports.parseLimitOption = parseLimitOption;
41
42
  exports.decideActions = decideActions;
42
43
  const commander_1 = require("commander");
@@ -68,7 +69,7 @@ const DP1_MAX_ITEMS = 1024;
68
69
  const RASTER_PAGE_SIZE = 100;
69
70
  exports.findCommand = new commander_1.Command('find')
70
71
  .description('Find an artwork on the web and build a DP-1 playlist')
71
- .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
72
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
72
73
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
73
74
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
74
75
  .option('-p, --play', 'Play the playlist on an FF1 device after building')
@@ -83,7 +84,7 @@ exports.findCommand = new commander_1.Command('find')
83
84
  const parsed = (0, marketplace_url_1.parseFindInput)(input);
84
85
  if (!parsed) {
85
86
  console.error(chalk_1.default.red('Could not understand input.'));
86
- console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
87
+ console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
87
88
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
88
89
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
89
90
  process.exit(1);
@@ -125,7 +126,7 @@ exports.findCommand = new commander_1.Command('find')
125
126
  seriesHasMore = fetched.hasMore;
126
127
  }
127
128
  else {
128
- tokens = [target.coords];
129
+ tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
129
130
  }
130
131
  if (tokens.length === 0) {
131
132
  console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
@@ -148,11 +149,25 @@ exports.findCommand = new commander_1.Command('find')
148
149
  // display seconds), not concurrency — concurrency is hardcoded inside.
149
150
  // Omit it for auto timing: video/audio items carry no duration and play
150
151
  // their natural length (DP-1 §4.1); static items get the config default.
151
- const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
152
+ let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
152
153
  chain: t.chain,
153
154
  contractAddress: t.contract,
154
155
  tokenId: t.tokenId,
155
156
  })));
157
+ // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
158
+ // Raster's tokens, so it returns nothing — but Raster handed us each
159
+ // token's playable `media.contentUrl`. Build DP-1 items straight from
160
+ // that (off-chain provenance, same shape as the Neort path), so the
161
+ // series still builds into a signable, verifiable playlist instead of
162
+ // failing the way it did before. The indexer is tried first so tokens
163
+ // it *can* resolve keep their richer on-chain provenance.
164
+ if (!Array.isArray(items) || items.length === 0) {
165
+ const rasterItems = buildRasterMediaItems(tokens, target);
166
+ if (rasterItems.length > 0) {
167
+ console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
168
+ items = rasterItems;
169
+ }
170
+ }
156
171
  if (!Array.isArray(items) || items.length === 0) {
157
172
  console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
158
173
  console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
@@ -217,6 +232,14 @@ async function resolveTarget(parsed, skipPrompt) {
217
232
  const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
218
233
  return resolveCoords(coords);
219
234
  }
235
+ if (parsed.kind === 'raster-artwork') {
236
+ const summary = await (0, raster_client_1.resolveSlugToArtwork)(parsed.slug);
237
+ if (summary === null) {
238
+ throw new Error(`No artwork found on Raster for slug "${parsed.slug}". ` +
239
+ 'Check the URL — Raster artwork URLs are raster.art/artwork/{slug}.');
240
+ }
241
+ return { kind: 'series', summary };
242
+ }
220
243
  if (parsed.kind === 'address') {
221
244
  const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
222
245
  if (artist === null) {
@@ -259,6 +282,32 @@ function playlistTitleFor(target, items) {
259
282
  }
260
283
  return items[0]?.title ?? `Token ${target.coords.tokenId}`;
261
284
  }
285
+ /**
286
+ * Build DP-1 items directly from Raster's per-token media, bypassing the FF
287
+ * indexer. Used as a fallback when the indexer resolves nothing — tokens
288
+ * without a Raster `mediaUrl` are dropped (nothing playable to point at).
289
+ *
290
+ * Item shape matches the Neort off-chain path (`buildUrlItem` →
291
+ * `provenance.offChainURI`), so the resulting playlist signs and verifies
292
+ * normally. Titles are "<Series> #<tokenId>" for series, falling back to the
293
+ * series/coords title for a single token.
294
+ */
295
+ function buildRasterMediaItems(tokens, target) {
296
+ const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
297
+ const items = [];
298
+ for (const t of tokens) {
299
+ if (!t.mediaUrl) {
300
+ continue;
301
+ }
302
+ const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
303
+ // No explicit duration: auto timing (video/audio play their natural
304
+ // length). `mediaType` carries Raster's type so an extensionless IPFS
305
+ // contentUrl is still classified correctly — otherwise a video would be
306
+ // stamped a fixed duration and play as a still.
307
+ items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
308
+ }
309
+ return items;
310
+ }
262
311
  function parseLimitOption(limitStr) {
263
312
  if (limitStr === undefined) {
264
313
  return Number.POSITIVE_INFINITY;
@@ -300,6 +349,8 @@ async function fetchTokens(summary, limit) {
300
349
  chain: t.chain,
301
350
  contract: t.contractAddress,
302
351
  tokenId: t.tokenId,
352
+ mediaUrl: t.mediaUrl,
353
+ mediaType: t.mediaType,
303
354
  });
304
355
  }
305
356
  if (hasMore || page.nextCursor === null) {
@@ -26,6 +26,7 @@ exports.parseOpenSea = parseOpenSea;
26
26
  exports.parseSuperRare = parseSuperRare;
27
27
  exports.parseNeort = parseNeort;
28
28
  exports.parseVerse = parseVerse;
29
+ exports.parseRaster = parseRaster;
29
30
  exports.parseFeralFile = parseFeralFile;
30
31
  const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
31
32
  const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
@@ -101,6 +102,9 @@ function parseMarketplaceUrl(url) {
101
102
  if (host === 'verse.works' || host.endsWith('.verse.works')) {
102
103
  return parseVerse(url);
103
104
  }
105
+ if (host === 'raster.art' || host.endsWith('.raster.art')) {
106
+ return parseRaster(url);
107
+ }
104
108
  return null;
105
109
  }
106
110
  /**
@@ -382,6 +386,27 @@ function parseVerse(url) {
382
386
  '/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
383
387
  };
384
388
  }
389
+ /**
390
+ * Raster URL forms (raster.art):
391
+ * raster.art/artwork/{slug} — artwork (series) detail page
392
+ *
393
+ * Unlike the other marketplaces, Raster is already the find flow's backend
394
+ * resolver — so a Raster URL resolves to an artwork by slug via
395
+ * `artworkBySlug` and (crucially) builds DP-1 items straight from Raster's
396
+ * own `media.contentUrl`, bypassing the FF indexer the way Neort does. This
397
+ * is what lets Raster-minted tokens (which the FF indexer doesn't carry)
398
+ * build a verifiable playlist without `--skip-verify`.
399
+ */
400
+ function parseRaster(url) {
401
+ const m = /^\/artwork\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
402
+ if (m) {
403
+ return { kind: 'raster-artwork', slug: m[1] };
404
+ }
405
+ return {
406
+ kind: 'unsupported',
407
+ reason: `Raster URL not recognized: ${url.pathname}. Expected /artwork/{slug}.`,
408
+ };
409
+ }
385
410
  /**
386
411
  * Feral File URL forms (source of truth: feralfile-client app-routing
387
412
  * + collection-routing modules):
@@ -536,6 +536,10 @@ function detectMimeType(url) {
536
536
  * (video/audio URLs play their natural length per DP-1 §4.1)
537
537
  * @param {Object} [options] - Optional configuration
538
538
  * @param {string} [options.title] - Optional item title override
539
+ * @param {string} [options.mimeType] - Source media type hint for timing
540
+ * (DP-1 §4.1). Needed when the URL has no file extension to detect from —
541
+ * e.g. extensionless IPFS gateway URLs, where without this hint a video
542
+ * would be misclassified as a static image and stamped a fixed duration.
539
543
  * @returns {Object} DP1 playlist item
540
544
  */
541
545
  function buildUrlItem(url, duration, options = {}) {
@@ -580,7 +584,7 @@ function buildUrlItem(url, duration, options = {}) {
580
584
  margin: 0,
581
585
  },
582
586
  };
583
- return applyItemTiming(item, { sourceUrl }, duration);
587
+ return applyItemTiming(item, { sourceUrl, mimeType: options.mimeType }, duration);
584
588
  }
585
589
  module.exports = {
586
590
  convertTokenToDP1Item,
@@ -58,6 +58,7 @@ var __importStar = (this && this.__importStar) || (function () {
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
60
  exports.resolveTokenToArtwork = resolveTokenToArtwork;
61
+ exports.resolveSlugToArtwork = resolveSlugToArtwork;
61
62
  exports.resolveAddressToArtist = resolveAddressToArtist;
62
63
  exports.listArtworkTokens = listArtworkTokens;
63
64
  exports.listArtistArtworks = listArtistArtworks;
@@ -150,6 +151,30 @@ async function resolveTokenToArtwork(chain, contract, tokenId) {
150
151
  artists: (artwork.artists ?? []).map(toRasterArtist),
151
152
  };
152
153
  }
154
+ /**
155
+ * Resolve a `raster.art/artwork/{slug}` URL to its artwork (series) summary.
156
+ *
157
+ * One round trip via `artworkBySlug`. Returns `null` when the slug is
158
+ * unknown (Raster models not-found as a null query field, not an HTTP 404).
159
+ */
160
+ async function resolveSlugToArtwork(slug) {
161
+ const data = await rasterQuery(`query ResolveSlug($slug: String!) {
162
+ artworkBySlug(slug: $slug) {
163
+ id
164
+ title
165
+ artists { id name slug }
166
+ }
167
+ }`, { slug });
168
+ const artwork = data.artworkBySlug;
169
+ if (!artwork) {
170
+ return null;
171
+ }
172
+ return {
173
+ artworkId: artwork.id,
174
+ title: artwork.title,
175
+ artists: (artwork.artists ?? []).map(toRasterArtist),
176
+ };
177
+ }
153
178
  /**
154
179
  * Resolve a wallet address to the Raster artist who claims it.
155
180
  *
@@ -180,7 +205,7 @@ async function listArtworkTokens(artworkId, options = {}) {
180
205
  const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
181
206
  artwork(id: $id) {
182
207
  tokens(first: $first, after: $after) {
183
- nodes { chainId contractAddress tokenId }
208
+ nodes { chainId contractAddress tokenId media { contentUrl contentType previewType } }
184
209
  pageInfo { hasNextPage endCursor }
185
210
  }
186
211
  }
@@ -200,6 +225,10 @@ async function listArtworkTokens(artworkId, options = {}) {
200
225
  chain,
201
226
  contractAddress: raw.contractAddress,
202
227
  tokenId: raw.tokenId,
228
+ mediaUrl: raw.media?.contentUrl ?? null,
229
+ // Prefer a real MIME (`contentType`) when Raster populates it; today it
230
+ // is usually empty, so fall back to the category-prefixed `previewType`.
231
+ mediaType: raw.media?.contentType || raw.media?.previewType || null,
203
232
  });
204
233
  }
205
234
  const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",