Purpose: This document helps you (the artist/developer) implement the HTML/JS artwork so it can be embedded on Feral File and other DP‑1 compatible displays. It defines how query parameters are used, how clips are resolved from IPFS/FF CDN, and how to call the on‑chain contract function
seriesArtworksOfOwner(uint256).
The artwork must accept the following query parameters:
-
contract– Ethereum contract address of the series (checksummed or lowercase). -
token_id– Token ID of the currently playing artwork (decimal string). -
mode– How to load clips:scene→ load a single clip for the giventoken_id.episode→ load multiple clips based onseriesArtworksOfOwner.
-
clip_directory_url(optional) – Base URL to load clips from.
-
If
clip_directory_urlis not provided, use the default:ipfs://<CID> -
Each clip URL follows this pattern:
:clip_directory_url/:token_idExample with IPFS CID
bafy...andtoken_id = 42:ipfs://bafy.../42
The artwork must call this contract function when mode=episode:
function seriesArtworksOfOwner(uint256 tokenId) external view returns (uint256[] memory);- Input:
tokenId– the currently playing token (token_idquery param). - Output: an array of token IDs that belong to the same owner and the same series/contract.
- The artwork uses this array to decide which clips to load for the episode mode.
- There are 111 clips in total.
- They will be uploaded to IPFS under a single directory CID.
- Feral File will also mirror them to a ** CDN** to speed up playback on Feral File surfaces.
The artwork is loaded in a browser with a URL like:
https://example.com/art.html
?contract=0x...
&token_id=123
&mode=scene
&clip_directory_url=ipfs://<CID>
Key rules:
- If
clip_directory_urlis provided → use that as the base for clips. - If
clip_directory_urlis missing → fall back to the default IPFS directory.
-
Load a single clip using the
token_id:effective_clip_url = resolved_clip_directory_url + '/' + token_id -
No contract calls are required.
- Take the
contractandtoken_idfrom query params. - Call
seriesArtworksOfOwner(token_id)on the given contract. - Receive an array of token IDs.
- Load clips for each of these token IDs from
clip_directory_url. - The artwork is free to decide ordering and timing (e.g., linear, shuffled, interactive).
-
Feral File will provide a default
ipfs://<CID>that should be reachable athttps://ipfs.feralfile.com/ipfs/<CID>. -
To make the artwork more durable and resilient, it’s recommended to:
-
Maintain a list of preferred IPFS gateways, for example:
https://ipfs.feralfile.com/ipfs/https://ipfs.io/ipfs/https://cloudflare-ipfs.com/ipfs/
-
When you see a
ipfs://<CID>/pathURL:- Extract the CID and path.
- Try each gateway in parallel by sending a lightweight
HEADrequest (or aGETwithmethod: 'HEAD'if CORS blocks true HEAD). - Use the first gateway that returns a successful response (status code
2xx).
-
To reduce the risk of RPC downtime or rate‑limit errors:
-
Keep a list of public RPC endpoints, such as:
https://eth.llamarpc.comhttps://cloudflare-eth.comhttps://rpc.ankr.com/ethhttps://ethereum-rpc.publicnode.comhttps://rpc.mevblocker.iohttps://eth.drpc.org
-
For
mode=episode:- Try
eth_callagainst all endpoints in parallel. - Use the first successful response.
- Try
This makes contract calls more reliable without tying the artwork to any specific provider.
function seriesArtworksOfOwner(uint256 tokenId) external view returns (uint256[] memory);-
Given a
tokenId:-
Find the owner of that token.
-
Return all token IDs that:
- Are owned by the same wallet address.
- Belong to the same series (same contract).
-
-
The artwork does not need to know how this is implemented; it only relies on the output array.
-
Read
contractandtoken_idfrom the URL. -
Build an ABI‑encoded call for
seriesArtworksOfOwner(token_id). -
Call
eth_callvia JSON‑RPC. -
Decode the returned
uint256[]. -
Use those token IDs to form clip URLs:
clip_url_for_token = resolved_clip_directory_url + '/' + tokenId
Note: This is a minimal, dependency‑free example meant for clarity. In production you can refactor or replace parts with your favorite web3 library. All logic runs in the browser.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>StarGuest Episode</title>
<style>
body { margin: 0; background: #000; color: #fff; font-family: system-ui, sans-serif; }
#debug { position: fixed; bottom: 0; left: 0; right: 0; max-height: 30vh; overflow: auto; font-size: 12px; background: rgba(0,0,0,0.6); padding: 8px; }
video { width: 100vw; height: 100vh; object-fit: cover; }
</style>
</head>
<body>
<video id="player" autoplay muted playsinline controls></video>
<pre id="debug"></pre>
<script>
// -----------------------------
// 0. Utility helpers
// -----------------------------
function log(msg, data) {
const debug = document.getElementById('debug');
const line = typeof data !== 'undefined' ? msg + ' ' + JSON.stringify(data) : msg;
debug.textContent += line + '\n';
}
function getQueryParams() {
const params = new URLSearchParams(window.location.search);
return {
contract: params.get('contract') || '',
tokenId: params.get('token_id') || '',
mode: (params.get('mode') || 'scene').toLowerCase(),
clipDirectoryUrl: params.get('clip_directory_url') || 'ipfs://QmaTR7merWY3APFTm3hHGFJ4JhRkk9J5PP7vA6zaSyMFX1'
};
}
// Small helper: "first successful" promise racer
async function firstSuccessful(promises) {
return new Promise((resolve, reject) => {
if (!promises.length) {
reject(new Error('No promises provided'));
return;
}
let pending = promises.length;
const errors = [];
for (const p of promises) {
p.then(result => {
// First one to resolve wins
resolve(result);
}).catch(err => {
errors.push(err);
pending -= 1;
if (pending === 0) {
reject(new Error('All promises failed: ' + errors.map(String).join('; ')));
}
});
}
});
}
// -----------------------------
// 1. IPFS URL → HTTP URL
// -----------------------------
const IPFS_GATEWAYS = [
'https://ipfs.feralfile.com/ipfs/',
'https://ipfs.io/ipfs/',
'https://cloudflare-ipfs.com/ipfs/'
];
async function resolveIpfsUrl(ipfsUrl) {
if (!ipfsUrl.startsWith('ipfs://')) return ipfsUrl; // already HTTP(S)
// ipfs://CID/path → CID, path
const withoutScheme = ipfsUrl.slice('ipfs://'.length);
const [cid, ...rest] = withoutScheme.split('/');
const path = rest.join('/');
const attempts = IPFS_GATEWAYS.map(gw => (async () => {
const httpUrl = gw + cid + (path ? '/' + path : '');
try {
const res = await fetch(httpUrl, { method: 'HEAD' });
if (!res.ok) {
throw new Error('HTTP status ' + res.status);
}
log('Using IPFS gateway', httpUrl);
return httpUrl;
} catch (e) {
log('Gateway failed', { gateway: gw, error: String(e) });
throw e;
}
})());
try {
// Race all gateways in parallel, take the first success
return await firstSuccessful(attempts);
} catch (e) {
// Fallback: just use the first gateway; browser may still succeed.
log('All gateway checks failed, falling back to first gateway', String(e));
return IPFS_GATEWAYS[0] + withoutScheme;
}
}
async function resolveClipBase(clipDirectoryUrl) {
// If clipDirectoryUrl is ipfs://..., convert to working HTTP URL for the *directory*.
if (clipDirectoryUrl.startsWith('ipfs://')) {
// Turn ipfs://CID into https://gateway/ipfs/CID and just return the base.
const withoutScheme = clipDirectoryUrl.slice('ipfs://'.length);
const [cid] = withoutScheme.split('/');
const base = 'ipfs://' + cid; // normalize to ipfs://CID, then resolve once
// Use the actual tokenId instead of a dummy token
return await resolveIpfsUrl(base);
}
return clipDirectoryUrl.replace(/\/$/, '');
}
// -----------------------------
// 2. JSON-RPC helpers (no web3 lib)
// -----------------------------
const ETH_RPC_ENDPOINTS = [
'https://eth.llamarpc.com',
'https://cloudflare-eth.com',
'https://rpc.ankr.com/eth',
'https://ethereum-rpc.publicnode.com',
'https://rpc.mevblocker.io',
'https://eth.drpc.org'
];
// Helper: encode uint256 as 32-byte hex string (no 0x prefix)
function encodeUint256(value) {
const bn = BigInt(value);
let hex = bn.toString(16);
if (hex.length > 64) {
throw new Error('uint256 too large');
}
while (hex.length < 64) hex = '0' + hex;
return hex;
}
// Function selector for seriesArtworksOfOwner(uint256)
// keccak256("seriesArtworksOfOwner(uint256)").slice(0, 4)
const SERIES_OF_OWNER_SELECTOR = '0x07b07ae9';
async function ethCallWithFallback(contract, data) {
const payload = {
jsonrpc: '2.0',
id: 1,
method: 'eth_call',
params: [
{
to: contract,
data: data
},
'latest'
]
};
const attempts = ETH_RPC_ENDPOINTS.map(url => (async () => {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
log('RPC HTTP error', { url, status: res.status });
throw new Error('HTTP status ' + res.status);
}
const json = await res.json();
if (json.error) {
log('RPC JSON error', { url, error: json.error });
throw new Error('RPC error ' + JSON.stringify(json.error));
}
log('RPC success', { url });
return json.result;
} catch (e) {
log('RPC network error', { url, error: String(e) });
throw e;
}
})());
// Race all RPCs in parallel, take the first successful result
return firstSuccessful(attempts);
}
// Decode a dynamic uint256[] from ABI-encoded return data
function decodeUint256Array(hexData) {
if (!hexData || hexData === '0x') return [];
let hex = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
// For a single dynamic uint256[] return, layout is:
// 0x
// [0..31] offset to array data (usually 0x20)
// [32..63] length (N)
// [64..] N * 32-byte elements
const offset = BigInt('0x' + hex.slice(0, 64));
const lengthPos = Number(offset) * 2; // hex chars per byte = 2
const length = BigInt('0x' + hex.slice(lengthPos, lengthPos + 64));
const result = [];
let cursor = lengthPos + 64; // first element
for (let i = 0n; i < length; i++) {
const word = hex.slice(cursor, cursor + 64);
result.push(BigInt('0x' + word));
cursor += 64;
}
return result;
}
async function fetchSeriesArtworksOfOwner(contract, tokenId) {
const data = SERIES_OF_OWNER_SELECTOR + encodeUint256(tokenId);
const raw = await ethCallWithFallback(contract, data);
const arr = decodeUint256Array(raw);
log('seriesArtworksOfOwner result', arr.map(String));
return arr.map(String); // convert BigInt[] → string[]
}
// -----------------------------
// 3. Playback logic
// -----------------------------
async function main() {
const { contract, tokenId, mode, clipDirectoryUrl } = getQueryParams();
log('Query params', { contract, tokenId, mode, clipDirectoryUrl });
if (!tokenId) {
log('Missing token_id; nothing to play');
return;
}
const clipBase = await resolveClipBase(clipDirectoryUrl);
const player = document.getElementById('player');
if (mode === 'scene') {
// Simple case: single clip for this token
const url = clipBase + '/' + tokenId;
log('Playing scene clip', url);
player.src = url;
player.play().catch(e => log('Play error', String(e)));
return;
}
if (!contract) {
log('Mode is episode but contract is missing; falling back to scene');
const url = clipBase + '/' + tokenId;
player.src = url;
player.play().catch(e => log('Play error', String(e)));
return;
}
// Episode mode: call contract and play multiple clips
let tokenIds;
try {
tokenIds = await fetchSeriesArtworksOfOwner(contract, tokenId);
} catch (e) {
log('Failed to load series tokens; falling back to single scene', String(e));
const url = clipBase + '/' + tokenId;
player.src = url;
player.play().catch(err => log('Play error', String(err)));
return;
}
if (!tokenIds.length) {
log('Empty series result; falling back to single scene');
const url = clipBase + '/' + tokenId;
player.src = url;
player.play().catch(err => log('Play error', String(err)));
return;
}
// Simple linear playlist over the owner’s tokens
let index = 0;
const playlist = tokenIds;
async function playCurrent() {
const tid = playlist[index];
const url = clipBase + '/' + tid;
log('Playing episode clip', { index, tokenId: tid, url });
player.src = url;
try {
await player.play();
} catch (e) {
log('Play error', String(e));
}
}
player.addEventListener('ended', () => {
index = (index + 1) % playlist.length; // loop
playCurrent();
});
await playCurrent();
}
main().catch(e => log('Fatal error', String(e)));
</script>
</body>
</html>-
Feral File will:
- Provide the final
clip_directory_urldefault (IPFS CID). - Provide correct
contractandtoken_idquery parameters when embedding the work. - Ensure the contract implements
seriesArtworksOfOwnerwith the expected semantics.
- Provide the final
-
You are free to:
- Change the visual design, layout, and timing of clips.
- Add additional UI elements (e.g., captions, overlays) as long as they don’t break the query‑param contract.
- Refactor code internally, as long as the external behavior described above stays the same.