@feralfile/cli 1.3.1 → 1.5.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 +1 -1
- package/dist/src/ai-orchestrator/index.js +23 -14
- package/dist/src/commands/find.js +61 -27
- package/dist/src/commands/play.js +3 -1
- package/dist/src/intent-parser/index.js +2 -2
- package/dist/src/intent-parser/utils.js +9 -6
- package/dist/src/main.js +5 -2
- package/dist/src/utilities/feed-fetcher.js +20 -13
- package/dist/src/utilities/index.js +10 -8
- package/dist/src/utilities/marketplace-url.js +7 -3
- package/dist/src/utilities/nft-indexer.js +9 -7
- package/dist/src/utilities/opensea-marketplace.js +123 -0
- package/dist/src/utilities/playlist-builder.js +92 -19
- package/dist/src/utilities/playlist-source.js +3 -1
- package/dist/src/utilities/raster-client.js +126 -127
- package/docs/CONFIGURATION.md +15 -1
- package/docs/FUNCTION_CALLING.md +3 -1
- package/docs/README.md +2 -1
- package/package.json +1 -1
package/dist/package.json
CHANGED
|
@@ -68,16 +68,19 @@ function inferToolCallFromContent(content, iterationCount) {
|
|
|
68
68
|
typeof parsed === 'object' &&
|
|
69
69
|
parsed.requirement &&
|
|
70
70
|
typeof parsed.requirement === 'object' &&
|
|
71
|
-
typeof parsed.duration === 'number') {
|
|
71
|
+
(parsed.duration === undefined || typeof parsed.duration === 'number')) {
|
|
72
|
+
// duration is optional: absent means auto timing (DP-1 §4.1), so the
|
|
73
|
+
// synthetic call must not fabricate one.
|
|
74
|
+
const syntheticArgs = { requirement: parsed.requirement };
|
|
75
|
+
if (typeof parsed.duration === 'number') {
|
|
76
|
+
syntheticArgs.duration = parsed.duration;
|
|
77
|
+
}
|
|
72
78
|
return {
|
|
73
79
|
id: `synthetic_query_requirement_${iterationCount}`,
|
|
74
80
|
type: 'function',
|
|
75
81
|
function: {
|
|
76
82
|
name: 'query_requirement',
|
|
77
|
-
arguments: JSON.stringify(
|
|
78
|
-
requirement: parsed.requirement,
|
|
79
|
-
duration: parsed.duration,
|
|
80
|
-
}),
|
|
83
|
+
arguments: JSON.stringify(syntheticArgs),
|
|
81
84
|
},
|
|
82
85
|
};
|
|
83
86
|
}
|
|
@@ -165,10 +168,10 @@ const functionSchemas = [
|
|
|
165
168
|
},
|
|
166
169
|
duration: {
|
|
167
170
|
type: 'number',
|
|
168
|
-
description: 'Display duration per item in seconds',
|
|
171
|
+
description: 'Display duration per item in seconds. Omit for auto timing: video/audio items then carry no duration and play their natural length (DP-1 §4.1); static items use the configured default.',
|
|
169
172
|
},
|
|
170
173
|
},
|
|
171
|
-
required: ['requirement'
|
|
174
|
+
required: ['requirement'],
|
|
172
175
|
},
|
|
173
176
|
},
|
|
174
177
|
},
|
|
@@ -207,10 +210,10 @@ const functionSchemas = [
|
|
|
207
210
|
},
|
|
208
211
|
duration: {
|
|
209
212
|
type: 'number',
|
|
210
|
-
description:
|
|
213
|
+
description: "Duration per item in seconds. Omit to keep each feed item's own timing.",
|
|
211
214
|
},
|
|
212
215
|
},
|
|
213
|
-
required: ['playlistName', 'quantity'
|
|
216
|
+
required: ['playlistName', 'quantity'],
|
|
214
217
|
},
|
|
215
218
|
},
|
|
216
219
|
},
|
|
@@ -474,6 +477,12 @@ function buildOrchestratorSystemPrompt(params) {
|
|
|
474
477
|
}
|
|
475
478
|
})
|
|
476
479
|
.join('\n');
|
|
480
|
+
// Spell out the duration argument once: an explicit setting is passed
|
|
481
|
+
// through verbatim; otherwise the model must OMIT duration so item-build
|
|
482
|
+
// applies auto timing (video/audio natural length per DP-1 §4.1).
|
|
483
|
+
const durationArgText = typeof playlistSettings.durationPerItem === 'number'
|
|
484
|
+
? `call query_requirement(requirement, duration=${playlistSettings.durationPerItem}).`
|
|
485
|
+
: 'call query_requirement(requirement) WITHOUT a duration argument (auto timing).';
|
|
477
486
|
const hasDevice = playlistSettings.deviceName !== undefined;
|
|
478
487
|
const sendStep = hasDevice
|
|
479
488
|
? `6) If verification passed → you MUST call send_to_device({ playlistId: <the_playlistId>, deviceName: "${playlistSettings.deviceName || 'first-device'}" }) before finishing.
|
|
@@ -488,7 +497,7 @@ REQUIREMENTS
|
|
|
488
497
|
${requirementsText}
|
|
489
498
|
|
|
490
499
|
PLAYLIST SETTINGS
|
|
491
|
-
- durationPerItem: ${playlistSettings.durationPerItem
|
|
500
|
+
- durationPerItem: ${typeof playlistSettings.durationPerItem === 'number' ? playlistSettings.durationPerItem : 'auto (omit duration: video/audio play natural length, static media uses configured default)'}
|
|
492
501
|
- title: ${playlistSettings.title || 'auto'}
|
|
493
502
|
- slug: ${playlistSettings.slug || 'auto'}
|
|
494
503
|
- preserveOrder: ${playlistSettings.preserveOrder !== false ? 'true' : 'false'}
|
|
@@ -517,14 +526,14 @@ KEY RULES
|
|
|
517
526
|
|
|
518
527
|
DECISION LOOP
|
|
519
528
|
1) For each requirement in order:
|
|
520
|
-
- build_playlist:
|
|
529
|
+
- build_playlist: ${durationArgText}
|
|
521
530
|
Returns array with minimal item data including id field. Collect the id values.
|
|
522
|
-
- feral_file_artwork:
|
|
531
|
+
- feral_file_artwork: ${durationArgText}
|
|
523
532
|
Returns array with minimal item data including id field. Collect the id values.
|
|
524
533
|
- query_address:
|
|
525
534
|
• if ownerAddress endsWith .eth/.tez → resolve_domains([domain]); if resolved → use returned address; if not → mark failed and continue.
|
|
526
|
-
• if ownerAddress is 0x…/tz… →
|
|
527
|
-
- fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity
|
|
535
|
+
• if ownerAddress is 0x…/tz… → ${durationArgText}
|
|
536
|
+
- fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity${typeof playlistSettings.durationPerItem === 'number' ? `, duration=${playlistSettings.durationPerItem}` : ''}).
|
|
528
537
|
- Collect item IDs across all steps in an array (let's call it collectedItemIds).
|
|
529
538
|
2) If zero items → explain briefly and finish.
|
|
530
539
|
3) If some requirements failed and interactive mode → ask user; otherwise proceed with available items.
|
|
@@ -47,6 +47,7 @@ const marketplace_url_1 = require("../utilities/marketplace-url");
|
|
|
47
47
|
const ff_marketplace_1 = require("../utilities/ff-marketplace");
|
|
48
48
|
const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
|
|
49
49
|
const ab_marketplace_1 = require("../utilities/ab-marketplace");
|
|
50
|
+
const opensea_marketplace_1 = require("../utilities/opensea-marketplace");
|
|
50
51
|
const fxhash_marketplace_1 = require("../utilities/fxhash-marketplace");
|
|
51
52
|
const neort_marketplace_1 = require("../utilities/neort-marketplace");
|
|
52
53
|
const verse_marketplace_1 = require("../utilities/verse-marketplace");
|
|
@@ -108,28 +109,45 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
108
109
|
}
|
|
109
110
|
console.log();
|
|
110
111
|
const userLimit = parseLimitOption(options.limit);
|
|
111
|
-
const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
|
|
112
112
|
// Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
|
|
113
113
|
// clear warning. Explicit `--limit > 1024` was already rejected by
|
|
114
114
|
// parseLimitOption, so reaching here means either capped-by-default or
|
|
115
115
|
// the user picked a value within spec.
|
|
116
116
|
const limit = Math.min(userLimit, DP1_MAX_ITEMS);
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Raster's GraphQL API exposes no series token counts, so enumerate
|
|
118
|
+
// tokens first (up to the effective limit) and confirm with the real
|
|
119
|
+
// number afterward. `hasMore` means the series continues past `limit`.
|
|
120
|
+
let tokens;
|
|
121
|
+
let seriesHasMore = false;
|
|
122
|
+
if (target.kind === 'series') {
|
|
123
|
+
const fetched = await fetchTokens(target.summary, limit);
|
|
124
|
+
tokens = fetched.tokens;
|
|
125
|
+
seriesHasMore = fetched.hasMore;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
tokens = [target.coords];
|
|
129
|
+
}
|
|
130
|
+
if (tokens.length === 0) {
|
|
131
|
+
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
|
|
135
|
+
console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
|
|
119
136
|
`Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
|
|
120
137
|
console.log();
|
|
121
138
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
139
|
+
const shouldBuild = !!options.yes ||
|
|
140
|
+
!!options.output ||
|
|
141
|
+
(await confirmMakePlaylist(tokens.length, seriesHasMore));
|
|
124
142
|
if (!shouldBuild) {
|
|
125
143
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
126
144
|
return;
|
|
127
145
|
}
|
|
128
|
-
const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
|
|
129
146
|
console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
|
|
130
147
|
// Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
|
|
131
148
|
// display seconds), not concurrency — concurrency is hardcoded inside.
|
|
132
|
-
// Omit it
|
|
149
|
+
// Omit it for auto timing: video/audio items carry no duration and play
|
|
150
|
+
// their natural length (DP-1 §4.1); static items get the config default.
|
|
133
151
|
const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
|
|
134
152
|
chain: t.chain,
|
|
135
153
|
contractAddress: t.contract,
|
|
@@ -183,6 +201,10 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
183
201
|
const coords = await (0, ab_marketplace_1.resolveArtBlocksCollection)(parsed.slug);
|
|
184
202
|
return resolveCoords(coords);
|
|
185
203
|
}
|
|
204
|
+
if (parsed.kind === 'os-collection') {
|
|
205
|
+
const coords = await (0, opensea_marketplace_1.resolveOpenSeaCollection)(parsed.slug);
|
|
206
|
+
return resolveCoords(coords);
|
|
207
|
+
}
|
|
186
208
|
if (parsed.kind === 'fxhash-iteration') {
|
|
187
209
|
const coords = await (0, fxhash_marketplace_1.resolveFxhashIteration)(parsed.slug);
|
|
188
210
|
return resolveCoords(coords);
|
|
@@ -196,32 +218,33 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
196
218
|
return resolveCoords(coords);
|
|
197
219
|
}
|
|
198
220
|
if (parsed.kind === 'address') {
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
221
|
+
const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
|
|
222
|
+
if (artist === null) {
|
|
201
223
|
throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
|
|
202
224
|
'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
|
|
203
225
|
}
|
|
204
|
-
const catalog = await (0, raster_client_1.listArtistArtworks)(
|
|
226
|
+
const catalog = await (0, raster_client_1.listArtistArtworks)(artist.id);
|
|
205
227
|
if (catalog.length === 0) {
|
|
206
228
|
throw new Error('Artist has no artworks indexed on Raster.');
|
|
207
229
|
}
|
|
208
230
|
const picked = await pickFromCatalog(catalog, skipPrompt);
|
|
209
|
-
|
|
210
|
-
|
|
231
|
+
return {
|
|
232
|
+
kind: 'series',
|
|
233
|
+
summary: { artworkId: picked.artworkId, title: picked.title, artists: [artist] },
|
|
234
|
+
};
|
|
211
235
|
}
|
|
212
236
|
// `unsupported` is handled upstream; this branch keeps the type narrowing honest.
|
|
213
237
|
throw new Error(`Unsupported parse result: ${parsed.kind}`);
|
|
214
238
|
}
|
|
215
239
|
/**
|
|
216
|
-
* Resolve on-chain coords to a target. If Raster doesn't index this token
|
|
217
|
-
*
|
|
240
|
+
* Resolve on-chain coords to a target. If Raster doesn't index this token,
|
|
241
|
+
* fall back to single-token mode so we still build something playable.
|
|
218
242
|
*/
|
|
219
243
|
async function resolveCoords(coords) {
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
244
|
+
const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
|
|
245
|
+
if (summary === null) {
|
|
222
246
|
return { kind: 'single', coords };
|
|
223
247
|
}
|
|
224
|
-
const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
|
|
225
248
|
return { kind: 'series', summary };
|
|
226
249
|
}
|
|
227
250
|
/**
|
|
@@ -252,11 +275,17 @@ function parseLimitOption(limitStr) {
|
|
|
252
275
|
}
|
|
253
276
|
return n;
|
|
254
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Enumerate series tokens from Raster up to `limit`. `hasMore` reports
|
|
280
|
+
* whether the series continues past what was collected — the API exposes
|
|
281
|
+
* no total counts, so this is all the size information the flow gets.
|
|
282
|
+
*/
|
|
255
283
|
async function fetchTokens(summary, limit) {
|
|
256
284
|
const collected = [];
|
|
257
285
|
let cursor;
|
|
258
286
|
let skipped = 0;
|
|
259
|
-
|
|
287
|
+
let hasMore = false;
|
|
288
|
+
for (;;) {
|
|
260
289
|
const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
|
|
261
290
|
cursor,
|
|
262
291
|
pageSize: RASTER_PAGE_SIZE,
|
|
@@ -264,6 +293,7 @@ async function fetchTokens(summary, limit) {
|
|
|
264
293
|
skipped += page.skippedUnsupported;
|
|
265
294
|
for (const t of page.tokens) {
|
|
266
295
|
if (collected.length >= limit) {
|
|
296
|
+
hasMore = true;
|
|
267
297
|
break;
|
|
268
298
|
}
|
|
269
299
|
collected.push({
|
|
@@ -272,7 +302,11 @@ async function fetchTokens(summary, limit) {
|
|
|
272
302
|
tokenId: t.tokenId,
|
|
273
303
|
});
|
|
274
304
|
}
|
|
275
|
-
if (page.nextCursor === null) {
|
|
305
|
+
if (hasMore || page.nextCursor === null) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (collected.length >= limit) {
|
|
309
|
+
hasMore = true;
|
|
276
310
|
break;
|
|
277
311
|
}
|
|
278
312
|
cursor = page.nextCursor;
|
|
@@ -280,7 +314,7 @@ async function fetchTokens(summary, limit) {
|
|
|
280
314
|
if (skipped > 0) {
|
|
281
315
|
console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
|
|
282
316
|
}
|
|
283
|
-
return collected;
|
|
317
|
+
return { tokens: collected, hasMore };
|
|
284
318
|
}
|
|
285
319
|
async function pickFromCatalog(catalog, skipPrompt) {
|
|
286
320
|
if (catalog.length === 1 || skipPrompt) {
|
|
@@ -288,8 +322,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
288
322
|
}
|
|
289
323
|
console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
|
|
290
324
|
catalog.forEach((row, i) => {
|
|
291
|
-
|
|
292
|
-
console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
|
|
325
|
+
console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
|
|
293
326
|
});
|
|
294
327
|
console.log();
|
|
295
328
|
const prompt = (0, prompt_1.createPrompt)();
|
|
@@ -304,9 +337,9 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
304
337
|
}
|
|
305
338
|
return catalog[index];
|
|
306
339
|
}
|
|
307
|
-
async function confirmMakePlaylist(count,
|
|
308
|
-
const noun =
|
|
309
|
-
const label =
|
|
340
|
+
async function confirmMakePlaylist(count, hasMore) {
|
|
341
|
+
const noun = count === 1 ? 'token' : 'tokens';
|
|
342
|
+
const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
|
|
310
343
|
const prompt = (0, prompt_1.createPrompt)();
|
|
311
344
|
const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
|
|
312
345
|
prompt.close();
|
|
@@ -482,12 +515,13 @@ async function runNeortFind(id, options) {
|
|
|
482
515
|
console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
|
|
483
516
|
console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
|
|
484
517
|
console.log();
|
|
485
|
-
const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1,
|
|
518
|
+
const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, false));
|
|
486
519
|
if (!shouldBuild) {
|
|
487
520
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
488
521
|
return;
|
|
489
522
|
}
|
|
490
|
-
|
|
523
|
+
// No explicit duration: auto timing (video/audio play natural length).
|
|
524
|
+
const item = buildUrlItem(art.assetUrl, undefined, { title: art.title });
|
|
491
525
|
const playlistTitle = `${artistLabel} — ${art.title}`;
|
|
492
526
|
const playlist = await buildDP1Playlist({ items: [item], title: playlistTitle });
|
|
493
527
|
const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
|
|
@@ -55,7 +55,9 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
55
55
|
.action(async (source, options) => {
|
|
56
56
|
try {
|
|
57
57
|
const config = (0, config_1.getConfig)();
|
|
58
|
-
|
|
58
|
+
// No explicit duration: a direct video/audio URL plays its natural
|
|
59
|
+
// length (DP-1 §4.1); static media uses config.defaultDuration.
|
|
60
|
+
const resolved = await (0, playlist_source_1.resolvePlaySource)(source);
|
|
59
61
|
const isPlaylistSource = resolved.kind === 'playlist';
|
|
60
62
|
const sourceLabel = isPlaylistSource
|
|
61
63
|
? `${resolved.sourceType}: ${resolved.source}`
|
|
@@ -164,7 +164,7 @@ EXAMPLES (fetch_feed)
|
|
|
164
164
|
- "Pick 3 artworks from Social Codes and 2 from a2p. Mix them up." → \`fetch_feed\` { playlistName: "Social Codes", quantity: 3 } + \`fetch_feed\` { playlistName: "a2p", quantity: 2 }, and set \`playlistSettings.preserveOrder\` = false
|
|
165
165
|
|
|
166
166
|
PLAYLIST SETTINGS EXTRACTION
|
|
167
|
-
- durationPerItem: parse phrases (e.g., "6 seconds each" → 6)
|
|
167
|
+
- durationPerItem: parse phrases (e.g., "6 seconds each" → 6); OMIT entirely when the user gives no per-item time — omitted means auto timing (video/audio play their natural length, static media uses the configured default). Never invent a number.
|
|
168
168
|
- preserveOrder: default true; synonyms ("shuffle", "randomize", "mix", "mix them up", "scramble") → false
|
|
169
169
|
- title/slug: optional; include only if provided by the user
|
|
170
170
|
- deviceName: from phrases like "send to", "display on", "play on", "push to"${hasDevices ? '\n- available devices:\n' + deviceInfo.replace('\n\nAVAILABLE FF1 DEVICES:\n', '') : ''}
|
|
@@ -341,7 +341,7 @@ const intentParserFunctionSchemas = [
|
|
|
341
341
|
},
|
|
342
342
|
durationPerItem: {
|
|
343
343
|
type: 'number',
|
|
344
|
-
description: 'Duration per item in seconds (e.g., 5 for "5 seconds each")',
|
|
344
|
+
description: 'Duration per item in seconds (e.g., 5 for "5 seconds each"). Omit when the user gives no per-item time: omitted = auto timing (video/audio play natural length per DP-1 §4.1).',
|
|
345
345
|
},
|
|
346
346
|
totalDuration: {
|
|
347
347
|
type: 'number',
|
|
@@ -15,10 +15,12 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
15
15
|
* @param {Object} params - Parsed parameters
|
|
16
16
|
* @param {Array<Object>} params.requirements - Array of requirements
|
|
17
17
|
* @param {Object} [params.playlistSettings] - Playlist settings
|
|
18
|
-
* @param {Object}
|
|
18
|
+
* @param {Object} _config - Application config (kept in the signature for
|
|
19
|
+
* call-site stability; no constraint reads it since durationPerItem
|
|
20
|
+
* pre-filling was removed in favor of DP-1 §4.1 auto timing)
|
|
19
21
|
* @returns {Object} Validated parameters
|
|
20
22
|
*/
|
|
21
|
-
function applyConstraints(params,
|
|
23
|
+
function applyConstraints(params, _config) {
|
|
22
24
|
// Validate requirements array
|
|
23
25
|
if (!params.requirements || !Array.isArray(params.requirements)) {
|
|
24
26
|
throw new Error('Requirements must be an array');
|
|
@@ -101,10 +103,11 @@ function applyConstraints(params, config) {
|
|
|
101
103
|
if (!params.playlistSettings) {
|
|
102
104
|
params.playlistSettings = {};
|
|
103
105
|
}
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// durationPerItem stays undefined unless the user asked for a per-item
|
|
107
|
+
// time: absence means auto timing downstream (video/audio omit duration
|
|
108
|
+
// and play their natural length per DP-1 §4.1; static media falls back to
|
|
109
|
+
// config.defaultDuration at item-build time). Pre-filling a number here
|
|
110
|
+
// would force-cut every video item.
|
|
108
111
|
// Only set preserveOrder if not already specified
|
|
109
112
|
if (params.playlistSettings.preserveOrder === undefined) {
|
|
110
113
|
params.playlistSettings.preserveOrder = true;
|
package/dist/src/main.js
CHANGED
|
@@ -228,11 +228,14 @@ function validateRequirements(requirements) {
|
|
|
228
228
|
* @returns {Object} Settings with defaults
|
|
229
229
|
*/
|
|
230
230
|
function applyPlaylistDefaults(settings = {}) {
|
|
231
|
-
const config = (0, config_1.getConfig)();
|
|
232
231
|
return {
|
|
233
232
|
title: settings.title || null,
|
|
234
233
|
slug: settings.slug || null,
|
|
235
|
-
|
|
234
|
+
// Leave durationPerItem undefined when not requested: absence means auto
|
|
235
|
+
// timing downstream (video/audio omit duration and play their natural
|
|
236
|
+
// length per DP-1 §4.1; static media falls back to config.defaultDuration
|
|
237
|
+
// at item-build time). Filling a number here would force-cut video items.
|
|
238
|
+
durationPerItem: settings.durationPerItem,
|
|
236
239
|
preserveOrder: settings.preserveOrder !== false,
|
|
237
240
|
deviceName: settings.deviceName,
|
|
238
241
|
};
|
|
@@ -333,7 +333,7 @@ function shuffleArray(array) {
|
|
|
333
333
|
*
|
|
334
334
|
* @param {Object} playlist - DP1 playlist object
|
|
335
335
|
* @param {number} quantity - Number of items to extract
|
|
336
|
-
* @param {number} duration -
|
|
336
|
+
* @param {number} [duration] - Explicit display seconds; omit to keep item timing
|
|
337
337
|
* @param {boolean} shuffle - Whether to shuffle and randomly select items
|
|
338
338
|
* @returns {Array<Object>} Array of DP1 playlist items
|
|
339
339
|
*/
|
|
@@ -348,12 +348,19 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
|
|
|
348
348
|
}
|
|
349
349
|
// Take requested quantity
|
|
350
350
|
items = items.slice(0, quantity);
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
351
|
+
// Apply an explicit duration override when one was requested; otherwise
|
|
352
|
+
// keep each item's own timing (including intentionally absent duration,
|
|
353
|
+
// which DP-1 §4.1 uses for natural-length playback of video/audio).
|
|
354
|
+
items = items.map((item) => {
|
|
355
|
+
const next = {
|
|
356
|
+
...item,
|
|
357
|
+
created: item.created || new Date().toISOString(), // Ensure created field exists
|
|
358
|
+
};
|
|
359
|
+
if (typeof duration === 'number') {
|
|
360
|
+
next.duration = duration;
|
|
361
|
+
}
|
|
362
|
+
return next;
|
|
363
|
+
});
|
|
357
364
|
return items;
|
|
358
365
|
}
|
|
359
366
|
/**
|
|
@@ -361,10 +368,10 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
|
|
|
361
368
|
*
|
|
362
369
|
* @param {string} playlistName - Exact playlist name
|
|
363
370
|
* @param {number} quantity - Number of items to fetch
|
|
364
|
-
* @param {number} duration -
|
|
371
|
+
* @param {number} [duration] - Explicit display seconds; omit to keep item timing
|
|
365
372
|
* @returns {Promise<Object>} Result with items
|
|
366
373
|
*/
|
|
367
|
-
async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration
|
|
374
|
+
async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration) {
|
|
368
375
|
const feedUrls = getFeedApiUrls();
|
|
369
376
|
console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
|
|
370
377
|
const result = await searchExactPlaylist(playlistName);
|
|
@@ -395,10 +402,10 @@ async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration = 10
|
|
|
395
402
|
*
|
|
396
403
|
* @param {string} playlistName - Playlist name (can be fuzzy)
|
|
397
404
|
* @param {number} quantity - Number of items to fetch
|
|
398
|
-
* @param {number} duration -
|
|
405
|
+
* @param {number} [duration] - Explicit display seconds; omit to keep item timing
|
|
399
406
|
* @returns {Promise<Object>} Result with best match and map for lookup
|
|
400
407
|
*/
|
|
401
|
-
async function searchFeedPlaylists(playlistName, quantity = 5, duration
|
|
408
|
+
async function searchFeedPlaylists(playlistName, quantity = 5, duration) {
|
|
402
409
|
const feedUrls = getFeedApiUrls();
|
|
403
410
|
console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
|
|
404
411
|
const result = await findBestMatchingPlaylist(playlistName);
|
|
@@ -429,12 +436,12 @@ async function searchFeedPlaylists(playlistName, quantity = 5, duration = 10) {
|
|
|
429
436
|
*
|
|
430
437
|
* @param {string} playlistIdOrName - Playlist ID, slug, or exact name
|
|
431
438
|
* @param {number} quantity - Number of items to fetch
|
|
432
|
-
* @param {number} duration -
|
|
439
|
+
* @param {number} [duration] - Explicit display seconds; omit to keep item timing
|
|
433
440
|
* @param {Object} playlistMap - Optional map of names to IDs for lookup
|
|
434
441
|
* @param {boolean} shuffle - Whether to shuffle and randomly select items
|
|
435
442
|
* @returns {Promise<Object>} Result with items
|
|
436
443
|
*/
|
|
437
|
-
async function fetchPlaylistItems(playlistIdOrName, quantity = 5, duration
|
|
444
|
+
async function fetchPlaylistItems(playlistIdOrName, quantity = 5, duration, playlistMap = null, shuffle = true) {
|
|
438
445
|
try {
|
|
439
446
|
let playlistId = playlistIdOrName;
|
|
440
447
|
let feedUrl = null;
|
|
@@ -95,10 +95,10 @@ function isLikelyEvmAddress(address) {
|
|
|
95
95
|
*
|
|
96
96
|
* @param {string} contractAddress - Contract address
|
|
97
97
|
* @param {number|string} [quantity] - Number of random tokens to select
|
|
98
|
-
* @param {number} duration -
|
|
98
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
99
99
|
* @returns {Promise<Array<Object>>} Array of DP1 items
|
|
100
100
|
*/
|
|
101
|
-
async function queryTokensByContractAddress(contractAddress, quantity, duration
|
|
101
|
+
async function queryTokensByContractAddress(contractAddress, quantity, duration) {
|
|
102
102
|
console.log(chalk.cyan(` Fetching ${quantity || 100} random token(s) from contract ${contractAddress.substring(0, 10)}...`));
|
|
103
103
|
const limit = Math.min(quantity || 100, 100);
|
|
104
104
|
const result = await nftIndexer.queryTokensByContract(contractAddress, limit);
|
|
@@ -141,10 +141,10 @@ function initializeUtilities(_config) {
|
|
|
141
141
|
*
|
|
142
142
|
* @param {string} ownerAddress - Owner wallet address
|
|
143
143
|
* @param {number|string} [quantity] - Number of random tokens to select, or "all" to fetch all tokens
|
|
144
|
-
* @param {number} duration -
|
|
144
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
145
145
|
* @returns {Promise<Array>} Array of DP1 playlist items
|
|
146
146
|
*/
|
|
147
|
-
async function queryTokensByAddress(ownerAddress, quantity, duration
|
|
147
|
+
async function queryTokensByAddress(ownerAddress, quantity, duration, options = {}) {
|
|
148
148
|
try {
|
|
149
149
|
const { suppressNotFoundGuidance = false } = options;
|
|
150
150
|
const shouldFetchAll = quantity === 'all' || quantity === undefined || quantity === null;
|
|
@@ -216,7 +216,7 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
|
|
|
216
216
|
if (typeof quantity === 'number' && selectedTokens.length > quantity) {
|
|
217
217
|
selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
|
|
218
218
|
}
|
|
219
|
-
const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration}`;
|
|
219
|
+
const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration ?? 'auto'}`;
|
|
220
220
|
if (!printedTokenCountKeys.has(tokenCountKey)) {
|
|
221
221
|
console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
|
|
222
222
|
printedTokenCountKeys.add(tokenCountKey);
|
|
@@ -243,10 +243,10 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
|
|
|
243
243
|
* @param {string} [requirement.ownerAddress] - Owner address (for query_address)
|
|
244
244
|
* @param {string} [requirement.playlistName] - Feed playlist name (for fetch_feed)
|
|
245
245
|
* @param {number} [requirement.quantity] - Number of items
|
|
246
|
-
* @param {number} duration -
|
|
246
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
247
247
|
* @returns {Promise<Array>} Array of DP1 playlist items
|
|
248
248
|
*/
|
|
249
|
-
async function queryRequirement(requirement, duration
|
|
249
|
+
async function queryRequirement(requirement, duration) {
|
|
250
250
|
const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity, artworkId, } = requirement;
|
|
251
251
|
// Handle query_address type
|
|
252
252
|
if (type === 'query_address') {
|
|
@@ -458,7 +458,9 @@ async function buildPlaylistDirect(params, options = {}) {
|
|
|
458
458
|
const { requirements, playlistSettings } = params;
|
|
459
459
|
const { verbose = false, outputPath = 'playlist.json' } = options;
|
|
460
460
|
const allItems = [];
|
|
461
|
-
|
|
461
|
+
// Undefined means auto timing: video/audio items omit duration and play
|
|
462
|
+
// their natural length (DP-1 §4.1); static items get the configured default.
|
|
463
|
+
const duration = playlistSettings.durationPerItem;
|
|
462
464
|
console.log(chalk.cyan('\nBuilding playlist from your requirements...\n'));
|
|
463
465
|
// Process each requirement
|
|
464
466
|
for (let i = 0; i < requirements.length; i++) {
|
|
@@ -246,7 +246,7 @@ function parseFxhash(url) {
|
|
|
246
246
|
* OpenSea URL forms:
|
|
247
247
|
* opensea.io/assets/{chain}/{contract}/{tokenId}
|
|
248
248
|
* opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
|
|
249
|
-
* opensea.io/collection/{slug} —
|
|
249
|
+
* opensea.io/collection/{slug} — collection slug (series)
|
|
250
250
|
*
|
|
251
251
|
* Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
|
|
252
252
|
* fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
|
|
@@ -270,11 +270,15 @@ function parseOpenSea(url) {
|
|
|
270
270
|
coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
|
+
const collectionMatch = /^\/collection\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
|
|
274
|
+
if (collectionMatch) {
|
|
275
|
+
return { kind: 'os-collection', slug: collectionMatch[1] };
|
|
276
|
+
}
|
|
273
277
|
if (url.pathname.startsWith('/collection/')) {
|
|
274
278
|
return {
|
|
275
279
|
kind: 'unsupported',
|
|
276
|
-
reason:
|
|
277
|
-
'
|
|
280
|
+
reason: `OpenSea collection URL not recognized: ${url.pathname}. ` +
|
|
281
|
+
'Expected opensea.io/collection/{slug} with no extra path segments.',
|
|
278
282
|
};
|
|
279
283
|
}
|
|
280
284
|
return {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql';
|
|
7
7
|
const logger = require('../logger');
|
|
8
|
+
const { applyItemTiming } = require('./playlist-builder');
|
|
8
9
|
// Polling configuration (in milliseconds)
|
|
9
10
|
const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
|
|
10
11
|
const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
|
|
@@ -354,10 +355,11 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
|
|
|
354
355
|
* animation_url > image.url, ensuring the best quality media is used.
|
|
355
356
|
*
|
|
356
357
|
* @param {Object} tokenData - Token data in standard format
|
|
357
|
-
* @param {number} duration -
|
|
358
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
359
|
+
* (video/audio play natural length per DP-1 §4.1)
|
|
358
360
|
* @returns {Object} DP1 item with source URL from indexer
|
|
359
361
|
*/
|
|
360
|
-
function convertToDP1Item(tokenData, duration
|
|
362
|
+
function convertToDP1Item(tokenData, duration) {
|
|
361
363
|
const { token } = tokenData;
|
|
362
364
|
if (!token) {
|
|
363
365
|
return {
|
|
@@ -435,7 +437,6 @@ function convertToDP1Item(tokenData, duration = 10) {
|
|
|
435
437
|
const dp1Item = {
|
|
436
438
|
id: itemId,
|
|
437
439
|
source: sourceUrl,
|
|
438
|
-
duration: duration,
|
|
439
440
|
license: 'open',
|
|
440
441
|
created: new Date().toISOString(),
|
|
441
442
|
provenance: {
|
|
@@ -452,6 +453,7 @@ function convertToDP1Item(tokenData, duration = 10) {
|
|
|
452
453
|
if (token.name) {
|
|
453
454
|
dp1Item.title = token.name;
|
|
454
455
|
}
|
|
456
|
+
applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
|
|
455
457
|
logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
|
|
456
458
|
title: token.name,
|
|
457
459
|
source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
|
|
@@ -464,11 +466,11 @@ function convertToDP1Item(tokenData, duration = 10) {
|
|
|
464
466
|
/**
|
|
465
467
|
* Get NFT token information from indexer and return as DP1 item (supports single or batch)
|
|
466
468
|
* @param {Object|Array} params - Token parameters (single object or array)
|
|
467
|
-
* @param {number} duration -
|
|
469
|
+
* @param {number} [params.duration] - Explicit display seconds; omit for auto timing
|
|
468
470
|
* @returns {Promise<Object>} DP1 item(s)
|
|
469
471
|
*/
|
|
470
472
|
async function getNFTTokenInfo(params) {
|
|
471
|
-
const duration = params.duration
|
|
473
|
+
const duration = params.duration;
|
|
472
474
|
// Handle array input for batch processing
|
|
473
475
|
if (Array.isArray(params.tokens)) {
|
|
474
476
|
return await getNFTTokenInfoBatch(params.tokens, duration);
|
|
@@ -495,7 +497,7 @@ async function getNFTTokenInfo(params) {
|
|
|
495
497
|
* @param {Object} [options.mediaPoll] - Passed to pollForMediaAssets
|
|
496
498
|
* @returns {Promise<Object>} DP1 item with success/error status
|
|
497
499
|
*/
|
|
498
|
-
async function getNFTTokenInfoSingle(params, duration
|
|
500
|
+
async function getNFTTokenInfoSingle(params, duration, options = {}) {
|
|
499
501
|
let chain = params.chain;
|
|
500
502
|
const { contractAddress, tokenId } = params;
|
|
501
503
|
// DEFENSIVE: Auto-detect and correct chain based on contract address format
|
|
@@ -596,7 +598,7 @@ async function getNFTTokenInfoSingle(params, duration = 10, options = {}) {
|
|
|
596
598
|
* @param {number} duration - Display duration in seconds
|
|
597
599
|
* @returns {Promise<Array>} Array of DP1 items
|
|
598
600
|
*/
|
|
599
|
-
async function getNFTTokenInfoBatch(tokens, duration
|
|
601
|
+
async function getNFTTokenInfoBatch(tokens, duration) {
|
|
600
602
|
logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
|
|
601
603
|
logger.debug('[NFT Indexer] Batch tokens:', tokens);
|
|
602
604
|
const results = [];
|