@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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Raster API client (
|
|
3
|
+
* Raster API client (api.raster.art — public GraphQL API).
|
|
4
4
|
*
|
|
5
|
-
* Resolves on-chain coordinates and artist addresses into Raster
|
|
6
|
-
* (series) and
|
|
5
|
+
* Resolves on-chain coordinates and artist addresses into Raster artworks
|
|
6
|
+
* (series) and artists (catalog) — the inputs the `ff-cli find` flow needs
|
|
7
7
|
* before handing tokens to the FF GraphQL indexer for DP-1 conversion.
|
|
8
8
|
*
|
|
9
9
|
* This module is pure data: it does not construct DP-1 items, send anything
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* mainnet. Token lists are filtered here at the CAIP level and a
|
|
15
15
|
* `skippedUnsupported` count is surfaced so callers can warn-skip without
|
|
16
16
|
* walking every row themselves.
|
|
17
|
+
*
|
|
18
|
+
* API notes (v0.1.0, https://docs.api.raster.art/):
|
|
19
|
+
* - Auth: `x-api-key` header, sent when RASTER_API_KEY is set in the
|
|
20
|
+
* environment. The endpoint currently answers without a key; the docs
|
|
21
|
+
* declare one required, so the hook is wired in ahead of enforcement.
|
|
22
|
+
* - Not-found is a `null` query field, not an HTTP 404.
|
|
23
|
+
* - Connections expose no total counts — callers learn series size by
|
|
24
|
+
* paginating, so the find flow fetches before it prompts.
|
|
17
25
|
*/
|
|
18
26
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
27
|
if (k2 === undefined) k2 = k;
|
|
@@ -49,130 +57,112 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
49
57
|
};
|
|
50
58
|
})();
|
|
51
59
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
-
exports.RasterNotFoundError = void 0;
|
|
53
60
|
exports.resolveTokenToArtwork = resolveTokenToArtwork;
|
|
54
|
-
exports.getArtworkSummary = getArtworkSummary;
|
|
55
61
|
exports.resolveAddressToArtist = resolveAddressToArtist;
|
|
56
62
|
exports.listArtworkTokens = listArtworkTokens;
|
|
57
63
|
exports.listArtistArtworks = listArtistArtworks;
|
|
58
64
|
exports.formatSummaryLine = formatSummaryLine;
|
|
59
65
|
const logger = __importStar(require("../logger"));
|
|
60
66
|
const user_agent_1 = require("./user-agent");
|
|
61
|
-
const
|
|
67
|
+
const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
|
|
62
68
|
// CAIP-2 chain IDs for the chains the FF indexer supports.
|
|
63
69
|
// Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
|
|
64
70
|
// not the human-readable "tezos:mainnet" — Raster returns the hash form on
|
|
65
|
-
// token rows and
|
|
71
|
+
// token rows and resolves nothing for "tezos:mainnet".
|
|
66
72
|
const CAIP_TO_INDEXER_CHAIN = {
|
|
67
73
|
'eip155:1': 'ethereum',
|
|
68
74
|
'tezos:NetXdQprcVkpaWU': 'tezos',
|
|
69
75
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
76
|
+
const INDEXER_CHAIN_TO_CAIP = {
|
|
77
|
+
ethereum: 'eip155:1',
|
|
78
|
+
tezos: 'tezos:NetXdQprcVkpaWU',
|
|
79
|
+
};
|
|
74
80
|
function caipToIndexerChain(caip) {
|
|
75
81
|
return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
|
|
76
82
|
}
|
|
77
83
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
84
|
+
* POST one GraphQL operation to Raster and return `data`.
|
|
85
|
+
*
|
|
86
|
+
* Throws on HTTP errors and on GraphQL `errors` entries. "Not found" never
|
|
87
|
+
* lands here — Raster models it as a null query field with no error, so
|
|
88
|
+
* callers check their own field for null.
|
|
81
89
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
async function rasterQuery(query, variables) {
|
|
91
|
+
const endpoint = process.env.RASTER_API_URL ?? DEFAULT_RASTER_GRAPHQL_URL;
|
|
92
|
+
logger.debug(`[Raster] POST ${endpoint} ${JSON.stringify(variables)}`);
|
|
93
|
+
const headers = {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
'User-Agent': user_agent_1.USER_AGENT,
|
|
96
|
+
};
|
|
97
|
+
const apiKey = process.env.RASTER_API_KEY;
|
|
98
|
+
if (apiKey) {
|
|
99
|
+
headers['x-api-key'] = apiKey;
|
|
87
100
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
logger.debug(`[Raster] GET ${url}`);
|
|
93
|
-
const response = await fetch(url, {
|
|
94
|
-
headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
|
|
101
|
+
const response = await fetch(endpoint, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers,
|
|
104
|
+
body: JSON.stringify({ query, variables }),
|
|
95
105
|
});
|
|
96
|
-
if (response.status === 404) {
|
|
97
|
-
throw new RasterNotFoundError(path);
|
|
98
|
-
}
|
|
99
106
|
if (!response.ok) {
|
|
100
107
|
const body = await response.text();
|
|
101
|
-
throw new Error(`Raster API ${response.status} ${response.statusText}
|
|
108
|
+
throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
|
|
109
|
+
}
|
|
110
|
+
const payload = (await response.json());
|
|
111
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
112
|
+
throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
|
|
113
|
+
}
|
|
114
|
+
if (payload.data === undefined) {
|
|
115
|
+
throw new Error('Raster API returned no data.');
|
|
102
116
|
}
|
|
103
|
-
return
|
|
117
|
+
return payload.data;
|
|
118
|
+
}
|
|
119
|
+
function toRasterArtist(raw) {
|
|
120
|
+
return { id: raw.id, name: raw.name ?? 'Unknown artist', slug: raw.slug };
|
|
104
121
|
}
|
|
105
122
|
/**
|
|
106
|
-
* Resolve a token's on-chain coordinates to its Raster artwork (series)
|
|
123
|
+
* Resolve a token's on-chain coordinates to its Raster artwork (series).
|
|
107
124
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
125
|
+
* One round trip: `tokenByRef` returns the artwork with artists inline, so
|
|
126
|
+
* this yields the full summary directly. Returns `null` when Raster doesn't
|
|
127
|
+
* index this token; callers fall back to a single-token playlist in that
|
|
128
|
+
* case. Tokens occasionally belong to multiple artworks; the first one is
|
|
129
|
+
* Raster's primary association.
|
|
112
130
|
*/
|
|
113
131
|
async function resolveTokenToArtwork(chain, contract, tokenId) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
throw error;
|
|
132
|
+
const data = await rasterQuery(`query ResolveToken($ref: TokenRefInput!) {
|
|
133
|
+
tokenByRef(ref: $ref) {
|
|
134
|
+
artworks { id title artists { id name slug } }
|
|
135
|
+
}
|
|
136
|
+
}`, {
|
|
137
|
+
ref: {
|
|
138
|
+
chainId: INDEXER_CHAIN_TO_CAIP[chain],
|
|
139
|
+
contractAddress: contract,
|
|
140
|
+
tokenId,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const artwork = data.tokenByRef?.artworks?.[0];
|
|
144
|
+
if (!artwork) {
|
|
145
|
+
return null;
|
|
129
146
|
}
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Get the full Raster artwork summary suitable for the one-line resolve
|
|
133
|
-
* message ("Snowfro — send/receive — 8977 tokens") and downstream UX.
|
|
134
|
-
*/
|
|
135
|
-
async function getArtworkSummary(artworkId) {
|
|
136
|
-
const data = await rasterFetch(`/artwork/${artworkId}`);
|
|
137
147
|
return {
|
|
138
|
-
artworkId:
|
|
139
|
-
title:
|
|
140
|
-
artists:
|
|
141
|
-
tokenCount: data.token_count,
|
|
142
|
-
editionSize: data.edition_size,
|
|
143
|
-
isEdition: data.is_edition,
|
|
144
|
-
hasOneOfOneTokens: data.has_one_of_one_tokens,
|
|
145
|
-
platforms: data.platforms ?? [],
|
|
146
|
-
contractAddresses: data.contract_addresses ?? [],
|
|
148
|
+
artworkId: artwork.id,
|
|
149
|
+
title: artwork.title,
|
|
150
|
+
artists: (artwork.artists ?? []).map(toRasterArtist),
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
/**
|
|
150
|
-
* Resolve
|
|
154
|
+
* Resolve a wallet address to the Raster artist who claims it.
|
|
151
155
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
156
|
+
* The `address` query carries artist associations directly (the old REST
|
|
157
|
+
* API needed a `/search` bridge). Returns `null` when no artist claims the
|
|
158
|
+
* address. Checksummed Ethereum input is normalized server-side.
|
|
155
159
|
*/
|
|
156
160
|
async function resolveAddressToArtist(address) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (doc.data_type !== 'artist') {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const addrs = (doc.addresses ?? []).map((a) => a.toLowerCase());
|
|
166
|
-
if (!addrs.includes(normalized)) {
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const parsed = Number.parseInt(doc.data_pk, 10);
|
|
170
|
-
if (Number.isFinite(parsed)) {
|
|
171
|
-
return parsed;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return null;
|
|
161
|
+
const data = await rasterQuery(`query ResolveAddress($address: String!) {
|
|
162
|
+
address(address: $address) { artists { id name slug } }
|
|
163
|
+
}`, { address: address.trim() });
|
|
164
|
+
const artist = data.address?.artists?.[0];
|
|
165
|
+
return artist ? toRasterArtist(artist) : null;
|
|
176
166
|
}
|
|
177
167
|
/**
|
|
178
168
|
* List tokens in a Raster artwork (series), one page at a time.
|
|
@@ -183,70 +173,79 @@ async function resolveAddressToArtist(address) {
|
|
|
183
173
|
* warn-skip line instead of one per token.
|
|
184
174
|
*
|
|
185
175
|
* Pagination: pass the returned `nextCursor` back as `options.cursor`.
|
|
186
|
-
* `nextCursor` is `null` on the final page.
|
|
187
|
-
*
|
|
176
|
+
* `nextCursor` is `null` on the final page. The API exposes no total count;
|
|
177
|
+
* the only way to learn a series' size is to walk it.
|
|
188
178
|
*/
|
|
189
179
|
async function listArtworkTokens(artworkId, options = {}) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
|
|
181
|
+
artwork(id: $id) {
|
|
182
|
+
tokens(first: $first, after: $after) {
|
|
183
|
+
nodes { chainId contractAddress tokenId }
|
|
184
|
+
pageInfo { hasNextPage endCursor }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
|
|
188
|
+
if (!data.artwork) {
|
|
189
|
+
throw new Error(`Raster: artwork ${artworkId} not found.`);
|
|
196
190
|
}
|
|
197
|
-
const qs = params.toString();
|
|
198
|
-
const path = `/artwork/${artworkId}/tokens${qs ? `?${qs}` : ''}`;
|
|
199
|
-
const data = await rasterFetch(path);
|
|
200
191
|
const tokens = [];
|
|
201
192
|
let skippedUnsupported = 0;
|
|
202
|
-
for (const raw of data.tokens
|
|
203
|
-
const chain = caipToIndexerChain(raw.
|
|
204
|
-
if (!chain) {
|
|
193
|
+
for (const raw of data.artwork.tokens.nodes) {
|
|
194
|
+
const chain = caipToIndexerChain(raw.chainId);
|
|
195
|
+
if (!chain || !raw.contractAddress) {
|
|
205
196
|
skippedUnsupported += 1;
|
|
206
197
|
continue;
|
|
207
198
|
}
|
|
208
199
|
tokens.push({
|
|
209
|
-
caipChain: raw.chain_id,
|
|
210
200
|
chain,
|
|
211
|
-
contractAddress:
|
|
212
|
-
tokenId: raw.
|
|
201
|
+
contractAddress: raw.contractAddress,
|
|
202
|
+
tokenId: raw.tokenId,
|
|
213
203
|
});
|
|
214
204
|
}
|
|
215
|
-
const
|
|
216
|
-
const nextCursor = rawCursor === null || rawCursor >= data.total_count ? null : rawCursor;
|
|
205
|
+
const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
|
|
217
206
|
return {
|
|
218
207
|
tokens,
|
|
219
|
-
|
|
220
|
-
nextCursor,
|
|
208
|
+
nextCursor: hasNextPage && endCursor ? endCursor : null,
|
|
221
209
|
skippedUnsupported,
|
|
222
210
|
};
|
|
223
211
|
}
|
|
224
212
|
/**
|
|
225
|
-
* List an artist's full catalog as light artwork rows (id
|
|
226
|
-
*
|
|
227
|
-
* are needed for a specific row.
|
|
213
|
+
* List an artist's full catalog as light artwork rows (id + title — the
|
|
214
|
+
* API exposes no per-artwork token counts or mint dates yet).
|
|
228
215
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
216
|
+
* Walks the artist's `artworks` connection to the end so the caller sees
|
|
217
|
+
* the complete catalog, matching the old single-response REST behavior.
|
|
231
218
|
*/
|
|
232
219
|
async function listArtistArtworks(artistId) {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
220
|
+
const rows = [];
|
|
221
|
+
let cursor = null;
|
|
222
|
+
do {
|
|
223
|
+
const data = await rasterQuery(`query ArtistArtworks($id: ID!, $after: String) {
|
|
224
|
+
artist(id: $id) {
|
|
225
|
+
artworks(first: 100, after: $after) {
|
|
226
|
+
nodes { id title }
|
|
227
|
+
pageInfo { hasNextPage endCursor }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}`, { id: artistId, after: cursor });
|
|
231
|
+
if (!data.artist) {
|
|
232
|
+
throw new Error(`Raster: artist ${artistId} not found.`);
|
|
233
|
+
}
|
|
234
|
+
for (const node of data.artist.artworks.nodes) {
|
|
235
|
+
rows.push({ artworkId: node.id, title: node.title });
|
|
236
|
+
}
|
|
237
|
+
const { hasNextPage, endCursor } = data.artist.artworks.pageInfo;
|
|
238
|
+
cursor = hasNextPage && endCursor ? endCursor : null;
|
|
239
|
+
} while (cursor !== null);
|
|
240
|
+
return rows;
|
|
241
241
|
}
|
|
242
242
|
/**
|
|
243
243
|
* Format a one-line summary for the resolved artwork.
|
|
244
244
|
*
|
|
245
245
|
* @example
|
|
246
|
-
* formatSummaryLine(summary) // "Snowfro — send/receive
|
|
246
|
+
* formatSummaryLine(summary) // "Snowfro — send/receive"
|
|
247
247
|
*/
|
|
248
248
|
function formatSummaryLine(summary) {
|
|
249
249
|
const artistNames = summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
|
|
250
|
-
|
|
251
|
-
return `${artistNames} — ${summary.title} — ${summary.tokenCount} ${tokenWord}`;
|
|
250
|
+
return `${artistNames} — ${summary.title}`;
|
|
252
251
|
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
|
@@ -115,6 +116,19 @@ Environment variable alternative:
|
|
|
115
116
|
FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
|
|
116
117
|
```
|
|
117
118
|
|
|
119
|
+
## find (Raster API)
|
|
120
|
+
|
|
121
|
+
`ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
|
|
122
|
+
|
|
123
|
+
```env
|
|
124
|
+
# API key sent as the x-api-key header. The API currently answers without
|
|
125
|
+
# one, but Raster's docs declare a key required — set this once enforced.
|
|
126
|
+
RASTER_API_KEY=rk_your_key_here
|
|
127
|
+
|
|
128
|
+
# Override the GraphQL endpoint (default: https://api.raster.art/graphql).
|
|
129
|
+
RASTER_API_URL=https://api.raster.art/graphql
|
|
130
|
+
```
|
|
131
|
+
|
|
118
132
|
## ff1Devices
|
|
119
133
|
|
|
120
134
|
Configure devices you want to play content on.
|
package/docs/FUNCTION_CALLING.md
CHANGED
|
@@ -75,12 +75,14 @@ Two options are available:
|
|
|
75
75
|
"quantity": 2
|
|
76
76
|
}
|
|
77
77
|
],
|
|
78
|
-
"playlistSettings": { "
|
|
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
|
@@ -148,6 +148,7 @@ Notes:
|
|
|
148
148
|
- Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
|
|
149
149
|
- `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
|
|
150
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)
|
|
151
152
|
- Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
|
|
152
153
|
- Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
|
|
153
154
|
- `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
|
|
@@ -343,7 +344,7 @@ Setup preserves existing devices when adding new ones. See selection rules and e
|
|
|
343
344
|
|
|
344
345
|
- Max 20 items total across all requirements
|
|
345
346
|
- Per-source caps enforced in utilities
|
|
346
|
-
-
|
|
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.
|
|
347
348
|
|
|
348
349
|
## Links
|
|
349
350
|
|