@feralfile/cli 1.3.0 → 1.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -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', 'duration'],
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: 'Duration per item in seconds',
213
+ description: "Duration per item in seconds. Omit to keep each feed item's own timing.",
211
214
  },
212
215
  },
213
- required: ['playlistName', 'quantity', 'duration'],
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 || 10}
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: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
529
+ - build_playlist: ${durationArgText}
521
530
  Returns array with minimal item data including id field. Collect the id values.
522
- - feral_file_artwork: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
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… → call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
527
- - fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity, duration=${playlistSettings.durationPerItem || 10}).
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,8 +47,10 @@ 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");
53
+ const verse_marketplace_1 = require("../utilities/verse-marketplace");
52
54
  const raster_client_1 = require("../utilities/raster-client");
53
55
  const ff1_device_1 = require("../utilities/ff1-device");
54
56
  // nft-indexer + playlist-builder are still CommonJS; require keeps interop simple.
@@ -66,7 +68,7 @@ const DP1_MAX_ITEMS = 1024;
66
68
  const RASTER_PAGE_SIZE = 100;
67
69
  exports.findCommand = new commander_1.Command('find')
68
70
  .description('Find an artwork on the web and build a DP-1 playlist')
69
- .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
71
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
70
72
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
71
73
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
72
74
  .option('-p, --play', 'Play the playlist on an FF1 device after building')
@@ -81,7 +83,7 @@ exports.findCommand = new commander_1.Command('find')
81
83
  const parsed = (0, marketplace_url_1.parseFindInput)(input);
82
84
  if (!parsed) {
83
85
  console.error(chalk_1.default.red('Could not understand input.'));
84
- console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort. ' +
86
+ console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
85
87
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
86
88
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
87
89
  process.exit(1);
@@ -128,7 +130,8 @@ exports.findCommand = new commander_1.Command('find')
128
130
  console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
129
131
  // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
130
132
  // display seconds), not concurrency — concurrency is hardcoded inside.
131
- // Omit it so the default (10s) applies; no misleading constant on this side.
133
+ // Omit it for auto timing: video/audio items carry no duration and play
134
+ // their natural length (DP-1 §4.1); static items get the config default.
132
135
  const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
133
136
  chain: t.chain,
134
137
  contractAddress: t.contract,
@@ -182,6 +185,10 @@ async function resolveTarget(parsed, skipPrompt) {
182
185
  const coords = await (0, ab_marketplace_1.resolveArtBlocksCollection)(parsed.slug);
183
186
  return resolveCoords(coords);
184
187
  }
188
+ if (parsed.kind === 'os-collection') {
189
+ const coords = await (0, opensea_marketplace_1.resolveOpenSeaCollection)(parsed.slug);
190
+ return resolveCoords(coords);
191
+ }
185
192
  if (parsed.kind === 'fxhash-iteration') {
186
193
  const coords = await (0, fxhash_marketplace_1.resolveFxhashIteration)(parsed.slug);
187
194
  return resolveCoords(coords);
@@ -190,6 +197,10 @@ async function resolveTarget(parsed, skipPrompt) {
190
197
  const coords = await (0, fxhash_marketplace_1.resolveFxhashProject)(parsed.slug);
191
198
  return resolveCoords(coords);
192
199
  }
200
+ if (parsed.kind === 'verse-series') {
201
+ const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
202
+ return resolveCoords(coords);
203
+ }
193
204
  if (parsed.kind === 'address') {
194
205
  const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
195
206
  if (artistId === null) {
@@ -482,7 +493,8 @@ async function runNeortFind(id, options) {
482
493
  console.log(chalk_1.default.dim('Cancelled.'));
483
494
  return;
484
495
  }
485
- const item = buildUrlItem(art.assetUrl, 10, { title: art.title });
496
+ // No explicit duration: auto timing (video/audio play natural length).
497
+ const item = buildUrlItem(art.assetUrl, undefined, { title: art.title });
486
498
  const playlistTitle = `${artistLabel} — ${art.title}`;
487
499
  const playlist = await buildDP1Playlist({ items: [item], title: playlistTitle });
488
500
  const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
@@ -1,24 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isMissingConfigValue = isMissingConfigValue;
3
+ exports.isMissingConfigValue = void 0;
4
4
  exports.readConfigFile = readConfigFile;
5
5
  exports.resolveExistingConfigPath = resolveExistingConfigPath;
6
6
  exports.ensureConfigFile = ensureConfigFile;
7
7
  const fs_1 = require("fs");
8
8
  const config_1 = require("../../config");
9
- // Treat any value containing the YOUR_/your_ placeholder pattern as missing,
10
- // since createSampleConfig writes those literal placeholders into new files
11
- // and we should not let the user proceed with an unfilled value.
12
- const placeholderPattern = /YOUR_|your_/;
13
- /**
14
- * Whether a config value is unset or still holds a sample placeholder.
15
- */
16
- function isMissingConfigValue(value) {
17
- if (!value) {
18
- return true;
19
- }
20
- return placeholderPattern.test(value);
21
- }
9
+ var config_placeholders_1 = require("../../utilities/config-placeholders");
10
+ Object.defineProperty(exports, "isMissingConfigValue", { enumerable: true, get: function () { return config_placeholders_1.isMissingConfigValue; } });
22
11
  /**
23
12
  * Read and parse a config.json file at the given path.
24
13
  */
@@ -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
- const resolved = await (0, playlist_source_1.resolvePlaySource)(source, config.defaultDuration || 10);
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}`
@@ -14,6 +14,7 @@ const config_1 = require("../config");
14
14
  const config_files_1 = require("./helpers/config-files");
15
15
  const device_discovery_1 = require("./helpers/device-discovery");
16
16
  const prompt_1 = require("./helpers/prompt");
17
+ const config_placeholders_1 = require("../utilities/config-placeholders");
17
18
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
18
19
  exports.setupCommand = new commander_1.Command('setup')
19
20
  .description('Guided setup for config, signing key, and device')
@@ -139,7 +140,12 @@ exports.setupCommand = new commander_1.Command('setup')
139
140
  role: signingRole,
140
141
  };
141
142
  }
142
- const existingDevices = config.ff1Devices?.devices || [];
143
+ const configuredDevices = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []);
144
+ const existingDevices = configuredDevices;
145
+ // Drop bundled example rows before discovery. Otherwise setup sees the
146
+ // sample placeholder as an existing device and lets users skip the device
147
+ // prompt without ever configuring a usable FF1 host.
148
+ config.ff1Devices = { devices: configuredDevices };
143
149
  if (existingDevices.length > 0) {
144
150
  console.log(chalk_1.default.dim(`\nConfigured devices: ${existingDevices.map((d) => d.name || d.host).join(', ')}`));
145
151
  }
@@ -198,7 +204,7 @@ exports.setupCommand = new commander_1.Command('setup')
198
204
  console.log(chalk_1.default.dim(` Config: ${configPath}`));
199
205
  const hasApiKey = !(0, config_files_1.isMissingConfigValue)(config.models[selectedModel]?.apiKey);
200
206
  const hasSigningKey = !(0, config_files_1.isMissingConfigValue)(config.playlist?.privateKey || '');
201
- const hasDevice = Boolean(config.ff1Devices?.devices?.[0]?.host);
207
+ const hasDevice = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []).length > 0;
202
208
  if (!hasSigningKey || !hasDevice) {
203
209
  console.log(chalk_1.default.yellow('\nNext steps:'));
204
210
  if (!hasSigningKey) {
@@ -10,6 +10,7 @@ const config_files_1 = require("./helpers/config-files");
10
10
  const config_1 = require("../config");
11
11
  const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
12
12
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
13
+ const config_placeholders_1 = require("../utilities/config-placeholders");
13
14
  exports.statusCommand = new commander_1.Command('status')
14
15
  .description('Show configuration status')
15
16
  .action(async () => {
@@ -32,6 +33,7 @@ exports.statusCommand = new commander_1.Command('status')
32
33
  const playlistKeyMaterial = playlistConfig.privateKey?.trim() || '';
33
34
  const playlistKeyError = playlistKeyMaterial.length > 0 ? validatePlaylistPrivateKey(playlistKeyMaterial) : null;
34
35
  const hasPlaylistSigningKey = playlistKeyMaterial.length > 0 && playlistKeyError === null;
36
+ const configuredDevices = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []);
35
37
  let hasValidPlaylistRole = false;
36
38
  let playlistRoleDetail;
37
39
  let playlistRoleError;
@@ -75,12 +77,10 @@ exports.statusCommand = new commander_1.Command('status')
75
77
  hint: ' (used when signing playlists)',
76
78
  },
77
79
  {
78
- label: `FF1 devices (${config.ff1Devices?.devices?.length || 0})`,
79
- ok: (config.ff1Devices?.devices?.length || 0) > 0 &&
80
- (config.ff1Devices?.devices || []).every((d) => !(0, config_files_1.isMissingConfigValue)(d.host)),
81
- detail: (config.ff1Devices?.devices || [])
82
- .map((d) => `${d.name || 'unnamed'} → ${d.host}`)
83
- .join(', ') || undefined,
80
+ label: `FF1 devices (${configuredDevices.length})`,
81
+ ok: configuredDevices.length > 0,
82
+ detail: configuredDevices.map((d) => `${d.name || 'unnamed'} → ${d.host}`).join(', ') ||
83
+ undefined,
84
84
  },
85
85
  ];
86
86
  console.log(chalk_1.default.blue('\n🔎 FF1 Status\n'));
@@ -18,6 +18,7 @@ const fs_1 = __importDefault(require("fs"));
18
18
  const path_1 = __importDefault(require("path"));
19
19
  const os_1 = __importDefault(require("os"));
20
20
  const playlist_signing_role_1 = require("./utilities/playlist-signing-role");
21
+ const config_placeholders_1 = require("./utilities/config-placeholders");
21
22
  // One-shot legacy config migration: copy `$XDG_CONFIG_HOME/ff1/config.json` to
22
23
  // `$XDG_CONFIG_HOME/ff-cli/config.json` on first read after upgrading from
23
24
  // `ff1-cli`. The check is cheap (two fs.existsSync calls) and the
@@ -285,7 +286,7 @@ function getFF1DeviceConfig() {
285
286
  const config = getConfig();
286
287
  const ff1Devices = config.ff1Devices || { devices: [] };
287
288
  return {
288
- devices: ff1Devices.devices || [],
289
+ devices: (0, config_placeholders_1.configuredFF1Devices)(ff1Devices.devices || []),
289
290
  };
290
291
  }
291
292
  /**
@@ -338,7 +339,7 @@ function validateConfig(modelName) {
338
339
  return { valid: false, errors };
339
340
  }
340
341
  const modelConfig = config.models[selectedModel];
341
- if (!modelConfig.apiKey || modelConfig.apiKey === 'your_api_key_here') {
342
+ if ((0, config_placeholders_1.isMissingConfigValue)(modelConfig.apiKey)) {
342
343
  errors.push(`API key for "${selectedModel}" is missing or not configured`);
343
344
  }
344
345
  if (!modelConfig.baseURL) {
@@ -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} config - Application config
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, config) {
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
- // Only set durationPerItem if not already specified
105
- if (params.playlistSettings.durationPerItem === undefined) {
106
- params.playlistSettings.durationPerItem = config.defaultDuration || 10;
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
- durationPerItem: settings.durationPerItem || config.defaultDuration || 10,
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
  };
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMissingConfigValue = isMissingConfigValue;
4
+ exports.configuredFF1Devices = configuredFF1Devices;
5
+ /**
6
+ * configPlaceholders identifies values copied from config.json.example.
7
+ *
8
+ * The sample file is useful for manual editing, but runtime flows must treat
9
+ * those literal placeholders as unset so setup cannot mistake example rows for
10
+ * configured devices.
11
+ */
12
+ const CONFIG_PLACEHOLDER_PATTERN = /YOUR_|your_/;
13
+ /**
14
+ * isMissingConfigValue reports whether a config string is empty or still a
15
+ * sample placeholder.
16
+ *
17
+ * @param {string | null | undefined} value - Config value to inspect.
18
+ * @returns {boolean} True when the value should be treated as unset.
19
+ */
20
+ function isMissingConfigValue(value) {
21
+ if (!value) {
22
+ return true;
23
+ }
24
+ return CONFIG_PLACEHOLDER_PATTERN.test(value);
25
+ }
26
+ /**
27
+ * configuredFF1Devices returns only device rows with a real host.
28
+ *
29
+ * @param {T[]} devices - Device rows from config.json.
30
+ * @returns {T[]} Rows that can be used for FF1 network operations.
31
+ */
32
+ function configuredFF1Devices(devices = []) {
33
+ return devices.filter((device) => !isMissingConfigValue(device.host));
34
+ }
@@ -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 - Duration per item in seconds
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
- // Override duration and ensure created field exists
352
- items = items.map((item) => ({
353
- ...item,
354
- duration: duration || item.duration,
355
- created: item.created || new Date().toISOString(), // Ensure created field exists
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 - Duration per item
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 = 10) {
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 - Duration per item
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 = 10) {
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 - Duration per item
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 = 10, playlistMap = null, shuffle = true) {
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 - Duration per item in seconds
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 = 10) {
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 - Duration per item in seconds
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 = 10, options = {}) {
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 - Duration per item in seconds
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 = 10) {
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
- const duration = playlistSettings.durationPerItem || 10;
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++) {
@@ -3,7 +3,7 @@
3
3
  * Marketplace URL parser for `ff-cli find`.
4
4
  *
5
5
  * Accepts user input in any of these forms and returns a normalized result:
6
- * - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort)
6
+ * - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse)
7
7
  * - Raw on-chain coordinates: "ethereum:0xabc...:123" / "tezos:KT1...:456"
8
8
  * - Wallet address: 0x{40-hex} (Ethereum) or tz1/tz2/tz3{33} (Tezos)
9
9
  *
@@ -25,6 +25,7 @@ exports.parseFxhash = parseFxhash;
25
25
  exports.parseOpenSea = parseOpenSea;
26
26
  exports.parseSuperRare = parseSuperRare;
27
27
  exports.parseNeort = parseNeort;
28
+ exports.parseVerse = parseVerse;
28
29
  exports.parseFeralFile = parseFeralFile;
29
30
  const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
30
31
  const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
@@ -97,6 +98,9 @@ function parseMarketplaceUrl(url) {
97
98
  if (host === 'neort.io' || host.endsWith('.neort.io')) {
98
99
  return parseNeort(url);
99
100
  }
101
+ if (host === 'verse.works' || host.endsWith('.verse.works')) {
102
+ return parseVerse(url);
103
+ }
100
104
  return null;
101
105
  }
102
106
  /**
@@ -242,7 +246,7 @@ function parseFxhash(url) {
242
246
  * OpenSea URL forms:
243
247
  * opensea.io/assets/{chain}/{contract}/{tokenId}
244
248
  * opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
245
- * opensea.io/collection/{slug} — series slug (not yet)
249
+ * opensea.io/collection/{slug} — collection slug (series)
246
250
  *
247
251
  * Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
248
252
  * fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
@@ -266,11 +270,15 @@ function parseOpenSea(url) {
266
270
  coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
267
271
  };
268
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
+ }
269
277
  if (url.pathname.startsWith('/collection/')) {
270
278
  return {
271
279
  kind: 'unsupported',
272
- reason: 'OpenSea `/collection/{slug}` URLs are not yet supported in v1. Paste a specific token ' +
273
- 'URL (opensea.io/assets/ethereum/{contract}/{tokenId}) or use `ethereum:{contract}:{tokenId}`.',
280
+ reason: `OpenSea collection URL not recognized: ${url.pathname}. ` +
281
+ 'Expected opensea.io/collection/{slug} with no extra path segments.',
274
282
  };
275
283
  }
276
284
  return {
@@ -337,6 +345,43 @@ function parseNeort(url) {
337
345
  reason: `Neort URL not recognized: ${url.pathname}. Expected /art/{id}.`,
338
346
  };
339
347
  }
348
+ /**
349
+ * Verse URL forms:
350
+ * verse.works/items/ethereum/{contract}/{tokenId} — direct Ethereum token
351
+ * verse.works/series/{slug} — series page
352
+ *
353
+ * Verse item pages expose the on-chain coordinates directly in the path. Series
354
+ * pages do not include a JSON API in the public URL, but they render edition
355
+ * links with the same `/items/ethereum/...` shape; the async resolver fetches
356
+ * the series page and extracts a representative token for Raster enumeration.
357
+ */
358
+ function parseVerse(url) {
359
+ let m = /^\/items\/ethereum\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
360
+ if (m) {
361
+ return {
362
+ kind: 'token',
363
+ source: 'verse',
364
+ coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
365
+ };
366
+ }
367
+ m = /^\/items\/([^/]+)\//.exec(url.pathname);
368
+ if (m) {
369
+ return {
370
+ kind: 'unsupported',
371
+ reason: `Verse ${m[1]} item URLs are not supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
372
+ 'Paste an Ethereum Verse item URL or raw `ethereum:{contract}:{tokenId}` coordinates.',
373
+ };
374
+ }
375
+ m = /^\/series\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
376
+ if (m) {
377
+ return { kind: 'verse-series', slug: m[1] };
378
+ }
379
+ return {
380
+ kind: 'unsupported',
381
+ reason: `Verse URL not recognized: ${url.pathname}. Expected ` +
382
+ '/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
383
+ };
384
+ }
340
385
  /**
341
386
  * Feral File URL forms (source of truth: feralfile-client app-routing
342
387
  * + collection-routing modules):
@@ -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
@@ -318,12 +319,14 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
318
319
  const artistName = extractArtistName(display.artists);
319
320
  const name = display.name || `Token #${indexerData.token_number}`;
320
321
  const description = display.description || '';
322
+ const standard = detectTokenStandard(chain, indexerData.contract_address);
321
323
  return {
322
324
  success: true,
323
325
  token: {
324
326
  chain,
325
327
  contractAddress: indexerData.contract_address,
326
328
  tokenId: indexerData.token_number,
329
+ standard,
327
330
  name,
328
331
  description,
329
332
  image: {
@@ -352,10 +355,11 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
352
355
  * animation_url > image.url, ensuring the best quality media is used.
353
356
  *
354
357
  * @param {Object} tokenData - Token data in standard format
355
- * @param {number} duration - Display duration in seconds
358
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
359
+ * (video/audio play natural length per DP-1 §4.1)
356
360
  * @returns {Object} DP1 item with source URL from indexer
357
361
  */
358
- function convertToDP1Item(tokenData, duration = 10) {
362
+ function convertToDP1Item(tokenData, duration) {
359
363
  const { token } = tokenData;
360
364
  if (!token) {
361
365
  return {
@@ -433,14 +437,13 @@ function convertToDP1Item(tokenData, duration = 10) {
433
437
  const dp1Item = {
434
438
  id: itemId,
435
439
  source: sourceUrl,
436
- duration: duration,
437
440
  license: 'open',
438
441
  created: new Date().toISOString(),
439
442
  provenance: {
440
443
  type: 'onChain',
441
444
  contract: {
442
445
  chain: chainMap[token.chain.toLowerCase()] || 'other',
443
- standard: 'other',
446
+ standard: token.standard || detectTokenStandard(token.chain, token.contractAddress),
444
447
  address: token.contractAddress,
445
448
  tokenId: String(token.tokenId),
446
449
  },
@@ -450,6 +453,7 @@ function convertToDP1Item(tokenData, duration = 10) {
450
453
  if (token.name) {
451
454
  dp1Item.title = token.name;
452
455
  }
456
+ applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
453
457
  logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
454
458
  title: token.name,
455
459
  source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
@@ -462,11 +466,11 @@ function convertToDP1Item(tokenData, duration = 10) {
462
466
  /**
463
467
  * Get NFT token information from indexer and return as DP1 item (supports single or batch)
464
468
  * @param {Object|Array} params - Token parameters (single object or array)
465
- * @param {number} duration - Display duration in seconds (default: 10)
469
+ * @param {number} [params.duration] - Explicit display seconds; omit for auto timing
466
470
  * @returns {Promise<Object>} DP1 item(s)
467
471
  */
468
472
  async function getNFTTokenInfo(params) {
469
- const duration = params.duration || 10;
473
+ const duration = params.duration;
470
474
  // Handle array input for batch processing
471
475
  if (Array.isArray(params.tokens)) {
472
476
  return await getNFTTokenInfoBatch(params.tokens, duration);
@@ -493,7 +497,7 @@ async function getNFTTokenInfo(params) {
493
497
  * @param {Object} [options.mediaPoll] - Passed to pollForMediaAssets
494
498
  * @returns {Promise<Object>} DP1 item with success/error status
495
499
  */
496
- async function getNFTTokenInfoSingle(params, duration = 10, options = {}) {
500
+ async function getNFTTokenInfoSingle(params, duration, options = {}) {
497
501
  let chain = params.chain;
498
502
  const { contractAddress, tokenId } = params;
499
503
  // DEFENSIVE: Auto-detect and correct chain based on contract address format
@@ -594,7 +598,7 @@ async function getNFTTokenInfoSingle(params, duration = 10, options = {}) {
594
598
  * @param {number} duration - Display duration in seconds
595
599
  * @returns {Promise<Array>} Array of DP1 items
596
600
  */
597
- async function getNFTTokenInfoBatch(tokens, duration = 10) {
601
+ async function getNFTTokenInfoBatch(tokens, duration) {
598
602
  logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
599
603
  logger.debug('[NFT Indexer] Batch tokens:', tokens);
600
604
  const results = [];
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * OpenSea marketplace resolver.
4
+ *
5
+ * OpenSea collection URLs (`opensea.io/collection/{slug}`) point to a series,
6
+ * not a token. OpenSea's REST API requires an API key, so this resolver reads
7
+ * the public collection page instead: the server-rendered HTML embeds the
8
+ * first page of collection items as relay-style JSON where each item carries
9
+ * `"chain":{"identifier":...},"contractAddress":"0x...","tokenId":"..."`
10
+ * adjacently. One extracted (contract, tokenId) pair is enough — the standard
11
+ * Raster reverse-lookup then enumerates the rest of the series like any other
12
+ * marketplace.
13
+ *
14
+ * Extraction invariants (what must not change):
15
+ * - Match only the adjacent `contractAddress` + `tokenId` pattern. The page
16
+ * also embeds payment-token contracts (WETH etc.) in other JSON shapes that
17
+ * never carry an adjacent tokenId, so adjacency is the noise filter.
18
+ * - The most frequent contract among matches wins (collection items dominate
19
+ * any stray adjacency), and its lowest tokenId is the deterministic seed.
20
+ * - tokenIds are compared as BigInt: ERC-721 ids can exceed Number range.
21
+ *
22
+ * Trade-off: scraping page HTML is less stable than a documented API, but the
23
+ * alternative (requiring every user to provision an OpenSea API key) defeats
24
+ * the paste-a-URL UX. Errors below name the fallback (`/item/` token URL) so
25
+ * a layout change degrades to a one-step workaround, not a dead end.
26
+ */
27
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ var desc = Object.getOwnPropertyDescriptor(m, k);
30
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
31
+ desc = { enumerable: true, get: function() { return m[k]; } };
32
+ }
33
+ Object.defineProperty(o, k2, desc);
34
+ }) : (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ o[k2] = m[k];
37
+ }));
38
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
39
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
40
+ }) : function(o, v) {
41
+ o["default"] = v;
42
+ });
43
+ var __importStar = (this && this.__importStar) || (function () {
44
+ var ownKeys = function(o) {
45
+ ownKeys = Object.getOwnPropertyNames || function (o) {
46
+ var ar = [];
47
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
48
+ return ar;
49
+ };
50
+ return ownKeys(o);
51
+ };
52
+ return function (mod) {
53
+ if (mod && mod.__esModule) return mod;
54
+ var result = {};
55
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
56
+ __setModuleDefault(result, mod);
57
+ return result;
58
+ };
59
+ })();
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ exports.resolveOpenSeaCollection = resolveOpenSeaCollection;
62
+ const logger = __importStar(require("../logger"));
63
+ const user_agent_1 = require("./user-agent");
64
+ /**
65
+ * One embedded collection item: chain identifier, contract, tokenId. The
66
+ * `[^{}]*` keeps the chain object match from swallowing unrelated JSON when
67
+ * OpenSea reorders fields inside `chain`.
68
+ */
69
+ const ITEM_PATTERN = /"chain":\{"identifier":"([a-z0-9_-]+)"[^{}]*\},"contractAddress":"(0x[a-fA-F0-9]{40})","tokenId":"(\d+)"/g;
70
+ const FALLBACK_HINT = 'Paste a specific token URL (opensea.io/item/ethereum/{contract}/{tokenId}) ' +
71
+ 'or use `ethereum:{contract}:{tokenId}`.';
72
+ /**
73
+ * Resolve an OpenSea collection slug to its contract address + the lowest
74
+ * tokenId visible on the collection page (a representative seed for the
75
+ * Raster series lookup).
76
+ *
77
+ * @throws When the page cannot be fetched, embeds no recognizable items
78
+ * (layout change or empty collection), or the collection is not on
79
+ * Ethereum mainnet (outside FF indexer coverage).
80
+ */
81
+ async function resolveOpenSeaCollection(slug) {
82
+ logger.debug(`[OpenSea] Resolving collection slug "${slug}"`);
83
+ const response = await fetch(`https://opensea.io/collection/${encodeURIComponent(slug)}`, {
84
+ headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'text/html' },
85
+ });
86
+ if (!response.ok) {
87
+ throw new Error(`OpenSea collection page returned ${response.status} ${response.statusText}. ` + FALLBACK_HINT);
88
+ }
89
+ const html = await response.text();
90
+ // Tally (contract → tokenIds) per chain so the dominant-contract rule and
91
+ // the ethereum-only guard can both give precise errors.
92
+ const byContract = new Map();
93
+ for (const match of html.matchAll(ITEM_PATTERN)) {
94
+ const [, chain, contract, tokenId] = match;
95
+ const key = contract.toLowerCase();
96
+ const entry = byContract.get(key) ?? { chain, tokenIds: [] };
97
+ entry.tokenIds.push(BigInt(tokenId));
98
+ byContract.set(key, entry);
99
+ }
100
+ if (byContract.size === 0) {
101
+ throw new Error(`OpenSea: no items found on the collection page for "${slug}". ` +
102
+ 'The collection may be empty, or the page layout changed. ' +
103
+ FALLBACK_HINT);
104
+ }
105
+ let best = null;
106
+ for (const [contract, entry] of byContract) {
107
+ if (!best || entry.tokenIds.length > best.tokenIds.length) {
108
+ best = { contract, ...entry };
109
+ }
110
+ }
111
+ // byContract.size > 0 guarantees best is set; the check keeps TS honest.
112
+ if (!best) {
113
+ throw new Error(`OpenSea: no items found for "${slug}". ${FALLBACK_HINT}`);
114
+ }
115
+ if (best.chain !== 'ethereum') {
116
+ throw new Error(`OpenSea collection "${slug}" is on ${best.chain} — ff-cli covers Ethereum and ` +
117
+ 'Tezos mainnet only. Paste an Ethereum-chain source instead.');
118
+ }
119
+ const seed = best.tokenIds.reduce((min, id) => (id < min ? id : min));
120
+ logger.debug(`[OpenSea] Resolved "${slug}" → ${best.contract} (seed tokenId ${seed}, ` +
121
+ `${best.tokenIds.length} items sampled)`);
122
+ return { chain: 'ethereum', contract: best.contract, tokenId: seed.toString() };
123
+ }
@@ -2,8 +2,72 @@
2
2
  * Playlist Builder Utilities
3
3
  * Core functions for building and validating DP1 playlists
4
4
  */
5
- const { getPlaylistConfig } = require('../config');
5
+ const { getPlaylistConfig, getConfig } = require('../config');
6
6
  const { signPlaylist } = require('./playlist-signer');
7
+ /**
8
+ * isTimeBasedMedia reports whether an item's media has an intrinsic runtime
9
+ * (video or audio) per DP-1 §4.1 "time-based sources".
10
+ *
11
+ * Two signals are consulted because neither is reliable alone: indexer
12
+ * `display.mime_type` is authoritative when present but is defaulted to
13
+ * 'image/png' upstream when missing, and URL extensions only cover direct
14
+ * media links. Either signal saying video/audio wins.
15
+ *
16
+ * @param {string} [mimeType] - MIME type reported by the indexer, if any
17
+ * @param {string} [sourceUrl] - Item source URL, used as an extension fallback
18
+ * @returns {boolean} True when the media is video or audio
19
+ */
20
+ function isTimeBasedMedia(mimeType, sourceUrl) {
21
+ const candidates = [String(mimeType || '').toLowerCase(), detectMimeType(sourceUrl)];
22
+ return candidates.some((mime) => mime.startsWith('video/') || mime.startsWith('audio/'));
23
+ }
24
+ /**
25
+ * applyItemTiming sets a DP1 item's playback timing per DP-1 §4.1.
26
+ *
27
+ * Invariant this function owns: an explicit numeric `duration` always wins
28
+ * and is stamped as-is. When `duration` is undefined/null ("auto"), a
29
+ * time-based source (video/audio) gets NO duration and `display.loop: false`,
30
+ * so a conformant player MUST advance at end-of-stream — the media plays its
31
+ * natural length. Static and code-based sources fall back to the configured
32
+ * `defaultDuration` because they have no intrinsic runtime to play out.
33
+ *
34
+ * Do not re-introduce an unconditional duration here: stamping a default on
35
+ * video items silently truncates or pads them (the bug this fixed).
36
+ *
37
+ * @param {Object} item - DP1 playlist item (mutated in place)
38
+ * @param {Object} mediaHints - Media signals for the time-based check
39
+ * @param {string} [mediaHints.mimeType] - Indexer-reported MIME type
40
+ * @param {string} [mediaHints.sourceUrl] - Item source URL
41
+ * @param {number} [duration] - Explicit display seconds; omit for auto
42
+ * @returns {Object} The same item, for chaining
43
+ */
44
+ function applyItemTiming(item, mediaHints = {}, duration) {
45
+ if (typeof duration === 'number') {
46
+ item.duration = duration;
47
+ return item;
48
+ }
49
+ if (isTimeBasedMedia(mediaHints.mimeType, mediaHints.sourceUrl)) {
50
+ item.display = { ...(item.display || {}), loop: false };
51
+ return item;
52
+ }
53
+ item.duration = getDefaultStaticDuration();
54
+ return item;
55
+ }
56
+ /**
57
+ * getDefaultStaticDuration returns the configured per-item display seconds
58
+ * for media without an intrinsic runtime. Falls back to 10 when config is
59
+ * unavailable (e.g. unit tests without a config file).
60
+ *
61
+ * @returns {number} Display duration in seconds
62
+ */
63
+ function getDefaultStaticDuration() {
64
+ try {
65
+ return getConfig().defaultDuration || 10;
66
+ }
67
+ catch (_error) {
68
+ return 10;
69
+ }
70
+ }
7
71
  /**
8
72
  * Convert a string to a URL-friendly slug
9
73
  *
@@ -31,14 +95,16 @@ function slugify(value) {
31
95
  * Convert single NFT token info to DP1 playlist item
32
96
  *
33
97
  * @param {Object} tokenInfo - Token information from NFT indexer
34
- * @param {number} duration - Display duration in seconds
98
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
99
+ * (video/audio play natural length per DP-1 §4.1, static media uses the
100
+ * configured default — see applyItemTiming)
35
101
  * @returns {Object} DP1 playlist item
36
102
  * @throws {Error} When token data is missing or source is a data URI
37
103
  * @example
38
104
  * const item = convertTokenToDP1ItemSingle(tokenInfo, 10);
39
105
  * // Returns: { title, source, duration, license, provenance, ... }
40
106
  */
41
- function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
107
+ function convertTokenToDP1ItemSingle(tokenInfo, duration) {
42
108
  const { token } = tokenInfo;
43
109
  if (!token) {
44
110
  throw new Error('Invalid token info: missing token data');
@@ -75,7 +141,6 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
75
141
  id: itemId,
76
142
  title: token.name || `Token #${token.tokenId}`,
77
143
  source: sourceUrl,
78
- duration: duration,
79
144
  license: 'token', // NFTs are token-gated by default
80
145
  created: new Date().toISOString(),
81
146
  provenance: {
@@ -102,7 +167,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
102
167
  if ((token.animation_url || token.animationUrl) && (token.image?.url || token.image)) {
103
168
  dp1Item.ref = token.image?.url || token.image;
104
169
  }
105
- return dp1Item;
170
+ return applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
106
171
  }
107
172
  /**
108
173
  * Convert NFT token info(s) to DP1 playlist item(s)
@@ -111,7 +176,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
111
176
  * For collections, returns a map of token key to DP1 item.
112
177
  *
113
178
  * @param {Object|Array} tokenInfo - Token information (single object or map of tokens)
114
- * @param {number} duration - Display duration in seconds
179
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
115
180
  * @returns {Object} Map of token key to DP1 playlist item, or single item
116
181
  * @example
117
182
  * // Single token
@@ -120,7 +185,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
120
185
  * // Multiple tokens
121
186
  * const items = convertTokenToDP1Item({ token1: info1, token2: info2 }, 10);
122
187
  */
123
- function convertTokenToDP1Item(tokenInfo, duration = 10) {
188
+ function convertTokenToDP1Item(tokenInfo, duration) {
124
189
  // Handle array or map of tokens
125
190
  if (typeof tokenInfo === 'object' && !tokenInfo.token) {
126
191
  const results = {};
@@ -163,12 +228,12 @@ function convertTokenToDP1Item(tokenInfo, duration = 10) {
163
228
  * Excludes items with data URIs in their source field.
164
229
  *
165
230
  * @param {Array} tokensInfo - Array of token information
166
- * @param {number} duration - Display duration in seconds
231
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
167
232
  * @returns {Array} Array of DP1 playlist items
168
233
  * @example
169
234
  * const items = convertTokensToDP1Items(tokensInfoArray, 10);
170
235
  */
171
- function convertTokensToDP1Items(tokensInfo, duration = 10) {
236
+ function convertTokensToDP1Items(tokensInfo, duration) {
172
237
  return tokensInfo
173
238
  .filter((info) => info.success && info.token)
174
239
  .map((info) => {
@@ -313,7 +378,10 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
313
378
  margin: 0,
314
379
  },
315
380
  license: 'token',
316
- duration: 10,
381
+ // No defaults.duration: items that should be timed carry an explicit
382
+ // duration already, and a playlist-level default would make conformant
383
+ // players time-cut items that intentionally omit duration to play
384
+ // their natural length (DP-1 §4.1 end-of-stream advance).
317
385
  },
318
386
  };
319
387
  // Always include slug (auto-generated when missing)
@@ -384,11 +452,14 @@ function validateDP1Playlist(playlist) {
384
452
  else if (typeof item.source !== 'string') {
385
453
  errors.push(`Item ${index}: Field "source" must be a string (URI)`);
386
454
  }
387
- if (item.duration === undefined || item.duration === null) {
388
- errors.push(`Item ${index}: Missing required field "duration"`);
389
- }
390
- else if (typeof item.duration !== 'number' || item.duration < 1) {
391
- errors.push(`Item ${index}: Field "duration" must be a number >= 1`);
455
+ // Duration is OPTIONAL per the DP-1 v1.1.0 schema (PlaylistItem requires
456
+ // only `source`; duration has `minimum: 0`). Absent duration is meaningful:
457
+ // time-based sources advance at end-of-stream (§4.1). Do not tighten this
458
+ // back to required — it would reject spec-valid playlists.
459
+ if (item.duration !== undefined && item.duration !== null) {
460
+ if (typeof item.duration !== 'number' || item.duration < 0) {
461
+ errors.push(`Item ${index}: Field "duration" must be a number >= 0`);
462
+ }
392
463
  }
393
464
  if (!item.license) {
394
465
  errors.push(`Item ${index}: Missing required field "license"`);
@@ -461,12 +532,13 @@ function detectMimeType(url) {
461
532
  * Build a single DP1 playlist item from a URL
462
533
  *
463
534
  * @param {string} url - Media URL
464
- * @param {number} duration - Duration per item in seconds
535
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
536
+ * (video/audio URLs play their natural length per DP-1 §4.1)
465
537
  * @param {Object} [options] - Optional configuration
466
538
  * @param {string} [options.title] - Optional item title override
467
539
  * @returns {Object} DP1 playlist item
468
540
  */
469
- function buildUrlItem(url, duration = 10, options = {}) {
541
+ function buildUrlItem(url, duration, options = {}) {
470
542
  const sourceUrl = String(url || '').trim();
471
543
  if (!sourceUrl) {
472
544
  throw new Error('URL is required to build a playlist item');
@@ -496,7 +568,6 @@ function buildUrlItem(url, duration = 10, options = {}) {
496
568
  id: itemId,
497
569
  title,
498
570
  source: sourceUrl,
499
- duration: duration,
500
571
  license: 'open',
501
572
  created: new Date().toISOString(),
502
573
  provenance: {
@@ -509,7 +580,7 @@ function buildUrlItem(url, duration = 10, options = {}) {
509
580
  margin: 0,
510
581
  },
511
582
  };
512
- return item;
583
+ return applyItemTiming(item, { sourceUrl }, duration);
513
584
  }
514
585
  module.exports = {
515
586
  convertTokenToDP1Item,
@@ -519,5 +590,7 @@ module.exports = {
519
590
  buildDP1Playlist,
520
591
  validateDP1Playlist,
521
592
  detectMimeType,
593
+ isTimeBasedMedia,
594
+ applyItemTiming,
522
595
  buildUrlItem,
523
596
  };
@@ -93,7 +93,9 @@ async function loadPlaylistSource(source) {
93
93
  * `loadedAsPlaylist` boolean dance.
94
94
  *
95
95
  * @param source - User-supplied path or URL
96
- * @param defaultDuration - Duration (seconds) for the synthesized media item
96
+ * @param defaultDuration - Explicit duration (seconds) for the synthesized
97
+ * media item; omit for auto timing (video/audio URLs play their natural
98
+ * length per DP-1 §4.1, static media uses the configured default)
97
99
  * @returns Resolved playlist + metadata describing how it was loaded
98
100
  * @throws Error When `source` is empty, or is a non-URL file path that cannot be loaded
99
101
  */
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveVerseSeries = resolveVerseSeries;
4
+ /**
5
+ * Resolve a Verse series slug to representative Ethereum token coordinates.
6
+ *
7
+ * Verse series pages render edition links as
8
+ * `/items/ethereum/{contract}/{tokenId}`. Using the first rendered edition
9
+ * keeps the resolver independent of private frontend state while still handing
10
+ * Raster a token it can use to enumerate the full series.
11
+ *
12
+ * @param slug - Verse series slug from `/series/{slug}`
13
+ * @returns Representative Ethereum token coordinates
14
+ * @throws Error when the page cannot be fetched or no Ethereum edition link is present
15
+ */
16
+ async function resolveVerseSeries(slug) {
17
+ const url = `https://verse.works/series/${encodeURIComponent(slug)}`;
18
+ const response = await fetch(url);
19
+ if (!response.ok) {
20
+ throw new Error(`Verse series lookup failed for ${slug}: ${response.status}`);
21
+ }
22
+ const html = await response.text();
23
+ const match = /\/items\/ethereum\/(0x[a-fA-F0-9]{40})\/(\d+)/.exec(html);
24
+ if (!match) {
25
+ throw new Error(`Verse series ${slug} did not expose an Ethereum edition link. ` +
26
+ 'Paste a specific Verse item URL or raw `ethereum:{contract}:{tokenId}` coordinates.');
27
+ }
28
+ return {
29
+ chain: 'ethereum',
30
+ contract: match[1].toLowerCase(),
31
+ tokenId: match[2],
32
+ };
33
+ }
@@ -26,7 +26,8 @@ npm run dev -- config show
26
26
  - Used by orchestration to pick API, timeouts, and model identifier.
27
27
 
28
28
  - **defaultDuration** (number, seconds)
29
- - Intended default per‑item display duration. Some flows pass an explicit duration; when omitted, utilities fall back to 10s.
29
+ - Default per‑item display duration for media without an intrinsic runtime (images, code-based works). Falls back to 10s when unset.
30
+ - Does NOT apply to video/audio items: with no explicit duration those are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) — the media plays its natural length.
30
31
 
31
32
  ## models
32
33
 
@@ -75,12 +75,14 @@ Two options are available:
75
75
  "quantity": 2
76
76
  }
77
77
  ],
78
- "playlistSettings": { "durationPerItem": 10, "preserveOrder": true, "title": "My Mix" }
78
+ "playlistSettings": { "preserveOrder": true, "title": "My Mix" }
79
79
  }
80
80
  ```
81
81
 
82
82
  This path bypasses the intent parser/orchestrator and calls utilities directly. Validation and sensible defaults are applied in `src/main.ts`.
83
83
 
84
+ `playlistSettings.durationPerItem` is optional. Omit it for auto timing: video/audio items are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) and the media plays its natural length; static items fall back to `defaultDuration`. Set `durationPerItem` only to force a fixed display time on every item.
85
+
84
86
  2. AI‑orchestrated deterministic build (recommended for prompts): Use `chat` with `--verbose` to see tool calls. The orchestrator enforces complete requirement objects, then runs `verify_playlist` (structure only) before sending.
85
87
 
86
88
  ## Extending Functionality (OSS‑first)
package/docs/README.md CHANGED
@@ -18,7 +18,7 @@ npm i -g @feralfile/cli
18
18
  curl -fsSL https://feralfile.com/ff-cli-install | bash
19
19
  ```
20
20
 
21
- Installs a prebuilt binary for macOS/Linux (no Node.js required).
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
22
 
23
23
  ## Configure
24
24
 
@@ -146,6 +146,11 @@ Notes:
146
146
  - Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`
147
147
  - `play <source>` – Play a playlist file, playlist URL, or media URL on an FF1 device (runs `verify` before sending; only the CLI-synthesized media URL fallback is auto-signed when a signing key is configured; use `--skip-verify` to bypass the gate)
148
148
  - Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
149
+ - `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
150
+ - Sources: Art Blocks, Objkt, fxhash, OpenSea, SuperRare, Feral File, Neort, Verse, raw on-chain coordinates, and wallet addresses
151
+ - OpenSea forms: `/assets/ethereum/{contract}/{tokenId}`, `/item/ethereum/{contract}/{tokenId}`, and `/collection/{slug}` (Ethereum collections; the slug is resolved from the public collection page, no API key needed)
152
+ - Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
153
+ - Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
149
154
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
150
155
  - Options: `-s, --server <index>` (server index if multiple configured)
151
156
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
@@ -339,7 +344,7 @@ Setup preserves existing devices when adding new ones. See selection rules and e
339
344
 
340
345
  - Max 20 items total across all requirements
341
346
  - Per-source caps enforced in utilities
342
- - Duration per item defaults to 10s (configurable)
347
+ - Per-item timing: when no duration is given, video/audio items carry **no** `duration` and `display.loop: false`, so the player advances at end-of-stream (DP-1 §4.1) — the media plays its natural length. Static items use `defaultDuration` (10s unless configured). An explicit duration always wins.
343
348
 
344
349
  ## Links
345
350
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",