From f02e8f09dc0be8b69769b45e3f8cf3f08a0bf7e4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 16:59:33 -0700 Subject: [PATCH] Package Whistlegraphs for Teia with generated animated covers --- oven/whistlegraph-preview.mjs | 52 ++++++++++++++++++ system/backend/mime-media.mjs | 6 +- system/backend/whistlegraph-mint.mjs | 31 ++++++++--- system/backend/whistlegraph-pack-worker.mjs | 4 +- system/backend/whistlegraph-pack.mjs | 18 ++++++ system/backend/whistlegraph-teia.mjs | 28 ++++++++++ .../netlify/functions/whistlegraph-mint.mjs | 4 +- system/public/mint/index.html | 7 ++- system/public/mint/mint.css | 4 ++ system/public/mint/mint.mjs | 33 ++++++++--- system/tests/mime-loading.test.mjs | 9 ++- system/tests/whistlegraph-mint-browser.mjs | 12 +++- system/tests/whistlegraph-mint.test.mjs | 55 +++++++++++++++++-- system/tests/whistlegraph-preview-browser.mjs | 23 ++++++++ 14 files changed, 257 insertions(+), 29 deletions(-) create mode 100644 oven/whistlegraph-preview.mjs create mode 100644 system/backend/whistlegraph-teia.mjs create mode 100644 system/tests/whistlegraph-preview-browser.mjs diff --git a/oven/whistlegraph-preview.mjs b/oven/whistlegraph-preview.mjs new file mode 100644 index 0000000000..a38feaa423 --- /dev/null +++ b/oven/whistlegraph-preview.mjs @@ -0,0 +1,52 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +import gifenc from 'gifenc'; +import { teiaIndexHTML } from '../system/backend/whistlegraph-teia.mjs'; + +// Capture the compiled artifact, never the mutable saved thread or a screenshot +// supplied by the client. The same HTML supplies both animation and thumbnail. +export async function renderWhistlegraphPreview(html, aspect = '2:3', options = {}) { + if (!['2:3','9:16','1:1','4:3','16:9'].includes(aspect)) throw new Error('Invalid preview aspect'); + const [x,y] = aspect.split(':').map(Number); + const width = Math.round(480 * x / Math.max(x,y)), height = Math.round(480 * y / Math.max(x,y)); + const count = 48, interval = 125; + const browser = await puppeteer.launch({ headless:true, executablePath:options.executablePath || process.env.PUPPETEER_EXECUTABLE_PATH, + args:['--no-sandbox','--disable-dev-shm-usage','--mute-audio','--enable-unsafe-swiftshader'] }); + const timer = setTimeout(() => browser.close().catch(() => browser.process()?.kill()), 90_000); + try { + const page = await browser.newPage(); + await page.setViewport({ width, height, deviceScaleFactor:1 }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.setRequestInterception(true); + page.on('request', request => { + if (request.url() === 'https://whistlegraph-preview.invalid/index.html') request.respond({ contentType:'text/html', body:teiaIndexHTML(html) }); + else if (/^(blob:|data:)/.test(request.url())) request.continue(); + else request.abort(); + }); + await page.goto('https://whistlegraph-preview.invalid/index.html', { waitUntil:'load', timeout:30_000 }); + await page.waitForSelector('canvas', { timeout:20_000 }); + await new Promise(r => setTimeout(r, 2500)); + const frames = []; + let thumbnail; + for (let i=0; i setTimeout(r, Math.max(0, interval - (Date.now()-started)))); + } + const encoder = gifenc.GIFEncoder(); + for (const rgba of frames) { + const palette = gifenc.quantize(rgba, 128); + const indexed = gifenc.applyPalette(rgba, palette); + encoder.writeFrame(indexed, width, height, { palette, delay:interval, repeat:0 }); + } + encoder.finish(); + const gif = Buffer.from(encoder.bytes()); + const info = await sharp(gif, { animated:true }).metadata(); + if (info.pages < 2) throw new Error('Preview animation is missing frames'); + return { gif, thumbnail, frames:info.pages, width, height, duration:count*interval }; + } finally { clearTimeout(timer); await browser.close(); } +} diff --git a/system/backend/mime-media.mjs b/system/backend/mime-media.mjs index 9a37e3308d..518130eab8 100644 --- a/system/backend/mime-media.mjs +++ b/system/backend/mime-media.mjs @@ -71,7 +71,7 @@ export function mediaPipeline(kind, match = {}, activity = true) { user: "$user", slug: "$slug", size: packed ? "$bytes" : "$size", hasPoster: { $ne: [{ $ifNull: ["$thumbnailUrl", ""] }, ""] }, ...(packed ? { title: "$title", version: "$version", aspect: "$aspect", - artifactUri: "$artifactUri", coverUri: "$coverUri", tokenId: "$tokenId" } : {}), + artifactUri: "$artifactUri", htmlUri: "$htmlUri", coverUri: "$coverUri", tokenId: "$tokenId" } : {}), }, } }, ]; @@ -136,7 +136,7 @@ export async function publicPosts(db, docs) { let original; if (media?.code) { const code = encodeURIComponent(media.code); - original = media.kind === "whistlegraph" ? packedMediaUrl(media.artifactUri) + original = media.kind === "whistlegraph" ? packedMediaUrl(media.htmlUri || media.artifactUri) : media.kind === "painting" ? `https://aesthetic.computer/#${code}` : media.kind === "tape" ? `https://aesthetic.computer/!${code}` : media.kind === "kidlisp" ? `https://aesthetic.computer/$${code}` @@ -160,7 +160,7 @@ export async function publicPosts(db, docs) { // Resolve storage only from an allowlisted, visible media record. Request // bodies cannot supply external URLs or choose arbitrary collections/buckets. export function mediaFile(kind, record) { - if (kind === "whistlegraph") return { url: packedMediaUrl(record.artifactUri) }; + if (kind === "whistlegraph") return { url: packedMediaUrl(record.htmlUri || record.artifactUri) }; if ((kind === "kidlisp" || kind === "piece") && typeof record.source === "string") { return { text: record.source }; } diff --git a/system/backend/whistlegraph-mint.mjs b/system/backend/whistlegraph-mint.mjs index 9c2848b4d6..e09d53d88e 100644 --- a/system/backend/whistlegraph-mint.mjs +++ b/system/backend/whistlegraph-mint.mjs @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { getPkhfromPk, verifySignature, validateAddress } from '@taquito/utils'; import { chainJSON } from './tezos-credits.mjs'; +import { teiaPackage, TEIA_FORMAT, TEIA_PACKAGE_VERSION } from './whistlegraph-teia.mjs'; export const HEN_MINTER = 'KT1Hkg5qeNhfwpKW4fXvq7HGZB9z2EnmCCA9'; export const HEN_OBJKTS = 'KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton'; @@ -10,6 +11,8 @@ const publicState = i => ({ id:i._id, code:i.code, version:i.version, sourceHash title:i.title, description:i.description, editions:i.editions, royalties:i.royalties, handle:i.handle, status:i.status, sender:i.sender, artifactUri:i.artifactUri, coverUri:i.coverUri, metadataUri:i.metadataUri, artifactHash:i.artifactHash, bytes:i.bytes, operationHash:i.operationHash, tokenId:i.tokenId, + htmlUri:i.htmlUri, zipUri:i.zipUri, thumbnailUri:i.thumbnailUri, artifactMimeType:i.artifactMimeType, + packageVersion:i.packageVersion, previewFrames:i.previewFrames, error:i.error, network:'mainnet', contract:HEN_OBJKTS }); export function mintPayload(i) { @@ -25,11 +28,18 @@ export function verifyMintWallet(i, proof) { } export function mintMetadata(i) { return { name:i.title, description:i.description, tags:['whistlegraph', 'aesthetic-computer', 'interactive', 'generative'], - symbol:'OBJKT', artifactUri:i.artifactUri, displayUri:i.coverUri, thumbnailUri:i.coverUri, - creators:[i.sender], formats:[{ uri:i.artifactUri, mimeType:'text/html' }], decimals:0, + symbol:'OBJKT', artifactUri:i.artifactUri, displayUri:i.coverUri, thumbnailUri:i.thumbnailUri, + creators:[i.sender], formats:[{ uri:i.artifactUri, mimeType:TEIA_FORMAT, fileName:`${i.code}-v${i.version}.zip`, fileSize:i.zipBytes }, + { uri:i.coverUri, mimeType:'image/gif', fileName:'cover.gif' }, + { uri:i.thumbnailUri, mimeType:'image/png', fileName:'thumbnail.png' }], decimals:0, isBooleanAmount:false, shouldPreferSymbol:false, date:new Date(i.createdAt).toISOString(), whistlegraph:{ code:i.code, version:i.version, sourceHash:i.sourceHash, artifactHash:i.artifactHash, - pixelSize:i.density, previewAspect:i.aspect, mintId:i._id, packVersion:i.packVersion } }; + pixelSize:i.density, previewAspect:i.aspect, mintId:i._id, packVersion:i.packVersion, + htmlUri:i.htmlUri, packageVersion:i.packageVersion, previewFrames:i.previewFrames } }; +} +function requireTeiaPackage(i) { + if (i.artifactMimeType !== TEIA_FORMAT || i.packageVersion !== TEIA_PACKAGE_VERSION || !i.thumbnailUri || !i.coverUri || !i.zipUri || !(i.previewFrames >= 2)) + throw mintError(409, 'This preview needs the corrected Teia package. Discard it and prepare a new preview.'); } export function mintOperation(i) { return { kind:'transaction', destination:HEN_MINTER, amount:'0', parameters:{ entrypoint:'mint_OBJKT', @@ -47,7 +57,7 @@ export function matchingMint(i, tx) { // Persist each step before opening a wallet. A lost browser callback recovers by // the unique metadata URI, never by an approximate amount or the latest token. -export function whistlegraphMints({ intents, threads, pack, pin, cover, chain = chainJSON, verify = verifyMintWallet, now = () => new Date() }) { +export function whistlegraphMints({ intents, threads, pack, pin, pinDirectory, cover, chain = chainJSON, verify = verifyMintWallet, now = () => new Date() }) { async function lookup(secret) { if (typeof secret !== 'string' || !/^[a-f0-9]{64}$/.test(secret)) throw mintError(401, 'Invalid mint link'); const i = await intents.findOne({ _id:hash(secret) }); @@ -101,9 +111,14 @@ export function whistlegraphMints({ intents, threads, pack, pin, cover, chain = if (!claimed.modifiedCount) return publicState(i); try { const packed = await pack(i); - const artifactUri = await pin('index.html', 'text/html', Buffer.from(packed.html)); - const coverUri = await pin('cover.png', 'image/png', Buffer.from(i.cover, 'base64')); - await intents.updateOne({ _id:i._id, status:'packing' }, { $set:{ status:'packed', artifactUri, coverUri, + const bundle = teiaPackage(packed.html, packed.preview); + const htmlUri = await pin('index.html', 'text/html', Buffer.from(packed.html)); + const artifactUri = await pinDirectory(bundle.files); + const coverUri = await pin('cover.gif', 'image/gif', Buffer.from(packed.preview.gif)); + const thumbnailUri = await pin('thumbnail.png', 'image/png', Buffer.from(packed.preview.thumbnail)); + const zipUri = await pin(`${i.code}-v${i.version}.zip`, 'application/zip', bundle.zip); + await intents.updateOne({ _id:i._id, status:'packing' }, { $set:{ status:'packed', artifactUri, coverUri, thumbnailUri, htmlUri, zipUri, + artifactMimeType:TEIA_FORMAT, packageVersion:TEIA_PACKAGE_VERSION, previewFrames:packed.preview.frames, zipBytes:bundle.zip.length, artifactHash:hash(packed.html), bytes:Buffer.byteLength(packed.html), packVersion:packed.version }, $unset:{ source:'', cover:'', lease:'' } }); } catch (error) { await intents.updateOne({ _id:i._id, status:'packing' }, { $set:{ status:'failed', error:'Packing failed. Open a new mint preview.' }, $unset:{ source:'', cover:'', lease:'' } }); @@ -114,6 +129,7 @@ export function whistlegraphMints({ intents, threads, pack, pin, cover, chain = async bind(secret, proof) { const i = await lookup(secret); await alive(i); + requireTeiaPackage(i); if (i.sender && i.sender !== proof.address) throw mintError(409, 'This mint belongs to a different wallet'); if (i.metadataUri) return publicState(i); if (i.status !== 'packed' && i.status !== 'binding') throw mintError(409, 'Wait for the HTML pack'); @@ -127,6 +143,7 @@ export function whistlegraphMints({ intents, threads, pack, pin, cover, chain = }, async begin(secret) { const i = await lookup(secret); await alive(i); + requireTeiaPackage(i); if (i.status !== 'ready') throw mintError(409, 'A wallet request is already pending. Check the mint before trying again.'); const changed = await intents.updateOne({ _id:i._id, status:'ready' }, { $set:{ status:'requested', requestedAt:now() } }); if (!changed.modifiedCount) throw mintError(409, 'A wallet request is already pending'); diff --git a/system/backend/whistlegraph-pack-worker.mjs b/system/backend/whistlegraph-pack-worker.mjs index 188ac1cc3c..60ed7ef670 100644 --- a/system/backend/whistlegraph-pack-worker.mjs +++ b/system/backend/whistlegraph-pack-worker.mjs @@ -3,6 +3,8 @@ import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; process.env.AC_SOURCE_DIR = fileURLToPath(new URL('../public/aesthetic.computer', import.meta.url)); const { createJSPieceBundleFromSource } = await import('../../oven/bundler.mjs'); +const { renderWhistlegraphPreview } = await import('../../oven/whistlegraph-preview.mjs'); const result = await createJSPieceBundleFromSource(`${workerData.code}-v${workerData.version}`, workerData.source, { density:workerData.density }); const version = execFileSync('git', ['rev-parse', 'HEAD'], { cwd:fileURLToPath(new URL('../../', import.meta.url)), encoding:'utf8' }).trim(); -parentPort.postMessage({ html:result.html, version }); +const preview = await renderWhistlegraphPreview(result.html, workerData.aspect); +parentPort.postMessage({ html:result.html, version, preview }); diff --git a/system/backend/whistlegraph-pack.mjs b/system/backend/whistlegraph-pack.mjs index 8181abec43..ae4cb72484 100644 --- a/system/backend/whistlegraph-pack.mjs +++ b/system/backend/whistlegraph-pack.mjs @@ -39,3 +39,21 @@ export async function pinMintFile(name, mime, content) { { method:'POST', signal:AbortSignal.timeout(120_000) }).catch(() => {}); return `ipfs://${cid}`; } + +export async function pinMintDirectory(files) { + if (!Array.isArray(files) || files.length !== 3 || new Set(files.map(file => file.name)).size !== 3) throw new Error('Incomplete Teia package'); + const form = new FormData(); + for (const file of files) { + if (!['index.html','cover.gif','thumbnail.png'].includes(file.name) || !file.content.length || file.content.length > 12_000_000) throw new Error('Invalid Teia package file'); + form.append('file', new Blob([file.content], { type:file.mime }), file.name); + } + const response = await fetch(`${process.env.IPFS_API_URL || 'http://localhost:5001'}/api/v0/add?pin=true&cid-version=0&wrap-with-directory=true`, + { method:'POST', body:form, signal:AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error('IPFS directory pin failed'); + const entries = (await response.text()).trim().split('\n').map(line => JSON.parse(line)); + const root = entries.find(entry => entry.Name === ''); + if (!/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(root?.Hash || '')) throw new Error('IPFS directory missing'); + fetch(`${process.env.IPFS_SEEDER_URL || 'http://137.184.237.166:5001'}/api/v0/pin/add?arg=${root.Hash}`, + { method:'POST', signal:AbortSignal.timeout(120_000) }).catch(() => {}); + return `ipfs://${root.Hash}`; +} diff --git a/system/backend/whistlegraph-teia.mjs b/system/backend/whistlegraph-teia.mjs new file mode 100644 index 0000000000..6e42b94fd4 --- /dev/null +++ b/system/backend/whistlegraph-teia.mjs @@ -0,0 +1,28 @@ +import AdmZip from 'adm-zip'; + +// Teia's interactive format is an IPFS directory, uploaded through its UI as +// a ZIP with index.html. A plain text/html artifact selects the unknown viewer. +// https://github.com/teia-community/teia-docs/blob/main/teia-docs/docs/howtos/Interactive-OBJKTs.md +export const TEIA_FORMAT = 'application/x-directory'; +export const TEIA_PACKAGE_VERSION = 1; +// Our packs need no network. This is a stricter subset of Teia's HTML policy; +// exercise the ZIP in Teia's own preview too, where Teia replaces this policy. +export const PACK_CSP = "default-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' blob: data:; worker-src 'self' blob:; frame-src 'self'; base-uri 'none'; form-action 'none'"; + +export function teiaIndexHTML(html) { + if (!/]*)?>/i.test(html)) throw new Error('Packed HTML needs a head'); + const clean = html.replace(/]*(?:http-equiv=["']Content-Security-Policy["']|property=["'](?:og:image|cover-image)["'])[^>]*>/gi, ''); + return clean.replace(/]*)?>/i, head => `${head}\n\n`); +} + +export function teiaPackage(html, preview) { + if (!preview.gif?.length || !preview.thumbnail?.length || preview.frames < 2) throw new Error('Animated cover and thumbnail required'); + const files = [ + { name:'index.html', mime:'text/html', content:Buffer.from(teiaIndexHTML(html)) }, + { name:'cover.gif', mime:'image/gif', content:Buffer.from(preview.gif) }, + { name:'thumbnail.png', mime:'image/png', content:Buffer.from(preview.thumbnail) }, + ]; + const zip = new AdmZip(); + for (const file of files) zip.addFile(file.name, file.content); + return { files, zip:zip.toBuffer() }; +} diff --git a/system/netlify/functions/whistlegraph-mint.mjs b/system/netlify/functions/whistlegraph-mint.mjs index 3851c5196f..7a84edf683 100644 --- a/system/netlify/functions/whistlegraph-mint.mjs +++ b/system/netlify/functions/whistlegraph-mint.mjs @@ -44,12 +44,12 @@ export function createHandler({ mints, authorize, handleFor, pilot = false, star const running = new Map(); export async function handler(event) { if (event.httpMethod !== 'POST' || event.body?.length > 1_500_000) return createHandler({})(event); - const [{ connect }, { authorize, getHandleOrEmail }, { packWhistlegraph, normalizeMintCover, pinMintFile }] = await Promise.all([ + const [{ connect }, { authorize, getHandleOrEmail }, { packWhistlegraph, normalizeMintCover, pinMintFile, pinMintDirectory }] = await Promise.all([ import('../../backend/database.mjs'), import('../../backend/authorization.mjs'), import('../../backend/whistlegraph-pack.mjs'), ]); const connection = await connect(); const mints = whistlegraphMints({ intents:connection.db.collection('whistlegraph-mints'), - threads:connection.db.collection('walkieware-threads'), pack:packWhistlegraph, cover:normalizeMintCover, pin:pinMintFile }); + threads:connection.db.collection('walkieware-threads'), pack:packWhistlegraph, cover:normalizeMintCover, pin:pinMintFile, pinDirectory:pinMintDirectory }); const start = secret => { if (running.has(secret) || running.size >= 2) return; const job = mints.prepare(secret).catch(error => console.error('Whistlegraph pack failed:', error.name)) diff --git a/system/public/mint/index.html b/system/public/mint/index.html index 54639be12f..e9477c9fe4 100644 --- a/system/public/mint/index.html +++ b/system/public/mint/index.html @@ -10,10 +10,15 @@

Mint on HEN

+ +

- +

Preparing the HTML pack…

diff --git a/system/public/mint/mint.css b/system/public/mint/mint.css index 1c98fa5b6b..82ace91e71 100644 --- a/system/public/mint/mint.css +++ b/system/public/mint/mint.css @@ -15,3 +15,7 @@ input { width:20px; height:20px; vertical-align:middle; } [hidden] { display:none !important; } a { color:LinkText; } #wallet { font:13px ui-monospace,monospace; } +#previews { display:flex; justify-content:center; gap:16px; margin:20px 0; } +figure { margin:0; text-align:center; flex:1; min-width:0; } +figure img { max-width:100%; height:140px; object-fit:contain; } +figcaption { font-size:14px; margin-top:6px; } diff --git a/system/public/mint/mint.mjs b/system/public/mint/mint.mjs index fc3760c162..c0b7614f0d 100644 --- a/system/public/mint/mint.mjs +++ b/system/public/mint/mint.mjs @@ -6,6 +6,14 @@ if (/^[a-f0-9]{64}$/.test(fragment)) { history.replaceState(null, '', location.pathname); } let client, intent, busy = false, polling = false; +const loaded = { art:false, cover:false, thumbnail:false }; +const compatible = () => intent?.artifactMimeType === 'application/x-directory' && intent.packageVersion === 1 && intent.previewFrames >= 2; +const updateMint = () => { $('mint').disabled = busy || !$('checked').checked || !compatible() || !Object.values(loaded).every(Boolean); }; +for (const id of Object.keys(loaded)) { + $(id).addEventListener('load', () => { loaded[id] = true; updateMint(); }); + $(id).addEventListener('error', () => { loaded[id] = false; updateMint(); }); +} +const gateway = uri => /^ipfs:\/\/Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(uri || '') ? 'https://ipfs.aesthetic.computer/ipfs/' + uri.slice(7) : null; const say = text => { $('status').textContent = text; }; async function api(action, body = {}) { const response = await fetch('/api/whistlegraph-mint', { method:'POST', @@ -22,17 +30,24 @@ function show(state) { $('art').dataset.aspect = intent.aspect || '2:3'; $('settings').textContent = `${intent.editions} edition${intent.editions === 1 ? '' : 's'} · ${intent.royalties / 10}% royalties · Tezos mainnet`; if (/^ipfs:\/\/Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(intent.artifactUri || '')) { - const src = 'https://ipfs.aesthetic.computer/ipfs/' + intent.artifactUri.slice(7); - if ($('art').src !== src) $('art').src = src; + const src = gateway(intent.artifactUri) + (compatible() ? '/' : ''); + if ($('art').src !== src) { loaded.art = false; $('art').src = src; } $('art').hidden = false; } + for (const [id, uri] of [['cover', intent.coverUri], ['thumbnail', intent.thumbnailUri]]) { + const src = gateway(uri); + if (src && $(id).src !== src) { loaded[id] = false; $(id).src = src; } + } + $('previews').hidden = !compatible(); + $('package').hidden = !gateway(intent.zipUri); + if (gateway(intent.zipUri)) { $('package').href = gateway(intent.zipUri); $('package').download = `${intent.code}-v${intent.version}.zip`; } $('wallet').textContent = intent.sender || ''; $('connect').hidden = !['packed','binding'].includes(intent.status); $('review').hidden = intent.status !== 'ready'; $('mint').hidden = intent.status !== 'ready'; - $('mint').disabled = busy || !$('checked').checked; + updateMint(); $('check').hidden = !['requested','confirming'].includes(intent.status); - if (intent.status === 'packing') say('Preparing the HTML pack…'); + if (intent.status === 'packing') say('Preparing the HTML pack, animated preview, and thumbnail…'); if (intent.status === 'packed') say('Play the packed version, then connect your wallet.'); if (intent.status === 'binding') say('Reconnect the same wallet to finish preparing metadata.'); if (intent.status === 'ready') say('Review this version, then approve minting in your wallet.'); @@ -40,12 +55,16 @@ function show(state) { if (intent.status === 'confirming') say('Mint seen. Waiting for three Tezos confirmations…'); if (intent.status === 'failed') say(intent.error); if (intent.status === 'cancelled') say('This preview was discarded. Return to Whistlegraph.'); + if (['packed','binding','ready'].includes(intent.status) && !compatible()) { + for (const id of ['connect','review','mint']) $(id).hidden = true; + say('This older preview is incompatible with Teia. Discard it in Whistlegraph and prepare a new preview.'); + } if (intent.status === 'minted') { say(`Minted OBJKT #${intent.tokenId}.`); if (/^\d+$/.test(intent.tokenId)) { $('token').href = `https://teia.art/objkt/${intent.tokenId}`; $('token').hidden = false; } } } -$('checked').onchange = () => { $('mint').disabled = busy || !$('checked').checked; }; +$('checked').onchange = updateMint; async function wallet() { if (!client) client = new window.beacon.DAppClient({ name:'Whistlegraph', network:{ type:'mainnet' }, enableMetrics:false, appUrl:'https://aesthetic.computer/mint/' }); @@ -62,7 +81,7 @@ async function action(work) { try { await work(); } catch (error) { say(error.message || 'Wallet request interrupted. Check the mint before trying again.'); } finally { busy = false; - $('connect').disabled = false; $('check').disabled = false; $('mint').disabled = !$('checked').checked; + $('connect').disabled = false; $('check').disabled = false; updateMint(); } } $('connect').onclick = () => action(async () => { @@ -72,7 +91,7 @@ $('connect').onclick = () => action(async () => { show(await api('bind', { address:account.address, publicKey:account.publicKey, signature:signed.signature })); }); $('mint').onclick = () => action(async () => { - if (!$('checked').checked || intent.status !== 'ready') return; + if (!$('checked').checked || !compatible() || !Object.values(loaded).every(Boolean) || intent.status !== 'ready') return; const account = await wallet(); if (account.address !== intent.sender) throw Error('Select the wallet shown on this mint.'); const begun = await api('begin'); diff --git a/system/tests/mime-loading.test.mjs b/system/tests/mime-loading.test.mjs index b39dfa1694..e1e0aadf82 100644 --- a/system/tests/mime-loading.test.mjs +++ b/system/tests/mime-loading.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {createHandler} from '../netlify/functions/mime.mjs'; -import {publicPosts, publicMediaUrl} from '../backend/mime-media.mjs'; +import {publicPosts, publicMediaUrl, mediaFile} from '../backend/mime-media.mjs'; const code='tape_6aa83d4ef78ff6472a0a64b2'; test('tape file and poster reads each validate current visibility with one lookup',async()=>{ let calls=0,visible=true; @@ -27,3 +27,10 @@ test('feed-only reads skip the global MIME census, normal inventory retains it', let r=await handler({httpMethod:'GET',queryStringParameters:{feed:'1'}});assert.equal(r.statusCode,200);assert.equal(census,0); r=await handler({httpMethod:'GET',queryStringParameters:{}});assert.equal(r.statusCode,200);assert.equal(census,1); }); +test('Teia directory packs retain a standalone HTML preview and download in Mime',async()=>{ + const htmlUri='ipfs://Qm'+'a'.repeat(44),artifactUri='ipfs://Qm'+'b'.repeat(44); + const [post]=await publicPosts({},[{code:'whistlegraph_'+'a'.repeat(64),parent:null,board:'text/html',_media:{kind:'whistlegraph',id:'id',code:'wgDefen',version:7,htmlUri,artifactUri,tokenId:'885470'}}]); + assert.equal(post.file.name,'wgDefen-v7.html');assert.equal(post.file.type,'text/html'); + assert.equal(post.media.url,'https://ipfs.aesthetic.computer/ipfs/'+htmlUri.slice(7)); + assert.equal(mediaFile('whistlegraph',{htmlUri,artifactUri}).url,post.media.url); +}); diff --git a/system/tests/whistlegraph-mint-browser.mjs b/system/tests/whistlegraph-mint-browser.mjs index 814111a3b5..accdf8ea26 100644 --- a/system/tests/whistlegraph-mint-browser.mjs +++ b/system/tests/whistlegraph-mint-browser.mjs @@ -2,10 +2,13 @@ import assert from 'node:assert/strict'; import {createServer} from 'node:http'; import {readFile} from 'node:fs/promises'; import {createRequire} from 'node:module'; +import sharp from 'sharp'; const require=createRequire(new URL('../../oven/package.json',import.meta.url)); const puppeteer=require('puppeteer'); const address='tz1inPpZMzFUv5mkmqDMEC8sYxmEq53vxRhw'; -let state={id:'test',code:'wgDefen',version:7,handle:'jeffrey',title:'Spinning tree',editions:1,royalties:150,status:'packed',payload:'0501',artifactUri:'ipfs://Qm'+'a'.repeat(44)}; +const pixel=await sharp({create:{width:16,height:16,channels:3,background:'green'}}).png().toBuffer(); +let state={id:'test',code:'wgDefen',version:7,handle:'jeffrey',title:'Spinning tree',editions:1,royalties:150,status:'packed',payload:'0501',artifactUri:'ipfs://Qm'+'a'.repeat(44), + artifactMimeType:'application/x-directory',packageVersion:1,previewFrames:48,coverUri:'ipfs://Qm'+'b'.repeat(44),thumbnailUri:'ipfs://Qm'+'c'.repeat(44),zipUri:'ipfs://Qm'+'d'.repeat(44)}; const calls=[]; const server=createServer(async(req,res)=>{ if(req.url==='/api/whistlegraph-mint') { @@ -32,7 +35,8 @@ try { const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); await page.setViewport({width:430,height:900,deviceScaleFactor:1}); await page.setRequestInterception(true);page.on('request',req=>{ - if(req.url().startsWith('https://ipfs.aesthetic.computer/ipfs/'))req.respond({status:200,contentType:'text/html',body:'Packed artwork'}); + if(req.url().startsWith('https://ipfs.aesthetic.computer/ipfs/Qm'+'b'.repeat(44))||req.url().startsWith('https://ipfs.aesthetic.computer/ipfs/Qm'+'c'.repeat(44)))req.respond({status:200,contentType:'image/png',body:pixel}); + else if(req.url().startsWith('https://ipfs.aesthetic.computer/ipfs/'))req.respond({status:200,contentType:'text/html',body:'Packed artwork'}); else if(req.url().startsWith(base))req.continue();else req.abort(); }); await page.goto(base+'/mint/#'+'a'.repeat(64));await page.waitForSelector('#connect:not([hidden])'); @@ -40,6 +44,8 @@ try { const frame=page.frames().find(f=>f.url().startsWith('https://ipfs.aesthetic.computer')); assert.equal(await frame.evaluate(()=>{try{void top.localStorage;return false;}catch{return true;}}),true); await page.click('#connect');await page.waitForSelector('#mint:not([hidden])'); + await page.waitForFunction(()=>document.getElementById('cover').naturalWidth&&document.getElementById('thumbnail').naturalWidth); + assert.equal(await page.$eval('#previews',e=>e.hidden),false); assert.equal(await page.$eval('#mint',e=>e.disabled),true); await page.click('#checked');assert.equal(await page.$eval('#mint',e=>e.disabled),false); await page.screenshot({path:'/tmp/whistlegraph-mint-review.png'}); @@ -49,5 +55,7 @@ try { assert.equal(await page.$eval('#mint',e=>e.hidden),true);assert.equal(calls.filter(c=>c==='begin').length,1); state={...state,status:'minted',tokenId:'900001'};await page.click('#check');await page.waitForSelector('#token:not([hidden])'); assert.equal(await page.$eval('#token',e=>e.href),'https://teia.art/objkt/900001'); + state={...state,status:'packed',packageVersion:0};await page.reload();await page.waitForFunction(()=>document.getElementById('status').textContent.includes('incompatible')); + for(const id of ['connect','review','mint'])assert.equal(await page.$eval('#'+id,e=>e.hidden),true); assert.deepEqual(errors,[]);console.log('PASS: mobile review, sandbox, wallet gating, interrupted callback, no repeat mint, token receipt'); }finally{await browser.close();await new Promise(r=>server.close(r));} diff --git a/system/tests/whistlegraph-mint.test.mjs b/system/tests/whistlegraph-mint.test.mjs index 0ed9ba44ca..99694bf24f 100644 --- a/system/tests/whistlegraph-mint.test.mjs +++ b/system/tests/whistlegraph-mint.test.mjs @@ -5,7 +5,10 @@ import { InMemorySigner } from '@taquito/signer'; import { b58cencode, prefix } from '@taquito/utils'; import sharp from 'sharp'; import { whistlegraphMints, hash, mintPayload, verifyMintWallet, matchingMint, mintOperation, HEN_MINTER, HEN_OBJKTS } from '../backend/whistlegraph-mint.mjs'; -import { normalizeMintCover } from '../backend/whistlegraph-pack.mjs'; +import { normalizeMintCover, pinMintDirectory } from '../backend/whistlegraph-pack.mjs'; +import { createServer } from 'node:http'; +import { teiaPackage, TEIA_FORMAT } from '../backend/whistlegraph-teia.mjs'; +import AdmZip from 'adm-zip'; import { createHandler } from '../netlify/functions/whistlegraph-mint.mjs'; function collection() { const docs = new Map(); @@ -45,7 +48,8 @@ async function fixture() { const source = 'export function paint({wipe}) { wipe("red"); }'; await threads.insertOne({ _id:'piece', owner:'user', code:'wgDefen', codeKey:'wgdefen', ledger:{ head:7, versions:[{ id:7, source }] } }); const mints = whistlegraphMints({ intents, threads, now:() => time, cover:async () => Buffer.from('cover'), - pack:async () => { state.packs++; return { html:'packed', version:'test' }; }, + pack:async () => { state.packs++; return { html:'packed', version:'test', preview:{ gif:Buffer.from('gif'), thumbnail:Buffer.from('png'), frames:48 } }; }, + pinDirectory:async files => { state.files = files; return 'ipfs://Qm' + 'a'.repeat(44); }, pin:async (name, mime, bytes) => { pins.push({ name, mime, bytes }); return 'ipfs://Qm' + hash(bytes).slice(0,44); }, verify:(_i, p) => p.signature === 'valid', chain:async path => path === '/head' ? state.head : path.startsWith('/tokens/') ? state.transfers : state.txs }); @@ -82,14 +86,20 @@ test('only the owner can snapshot a saved exact version; a retry preserves its s await assert.rejects(f.mints.create('stranger', '@other', f.input), /another account/); assert.equal(f.intents.docs.size, 1); }); -test('packing is claimed once, removes temporary source/cover, and pins HTML plus cover', async () => { +test('packing is claimed once and publishes a Teia directory, ZIP, HTML, GIF and thumbnail', async () => { const f = await fixture(); await f.mints.create('user', '@test', f.input); await Promise.all([f.mints.prepare(f.secret), f.mints.prepare(f.secret)]); assert.equal(f.state.packs, 1); - assert.deepEqual(f.pins.map(p => p.mime), ['text/html','image/png']); + assert.deepEqual(f.pins.map(p => p.mime), ['text/html','image/gif','image/png','application/zip']); + assert.deepEqual(f.state.files.map(p => p.name), ['index.html','cover.gif','thumbnail.png']); + const zip = new AdmZip(f.pins.find(p => p.mime === 'application/zip').bytes); + assert.match(zip.readAsText('index.html'), /property="og:image" content="cover.gif"/); + assert.match(zip.readAsText('index.html'), /Content-Security-Policy/); const i = [...f.intents.docs.values()][0]; assert.equal(i.status, 'packed'); assert.equal(i.source, undefined); assert.equal(i.cover, undefined); - assert.equal(i.artifactHash, hash('packed')); + assert.equal(i.artifactHash, hash('packed')); + assert.equal(i.artifactMimeType, TEIA_FORMAT); + assert.equal(i.previewFrames, 48); }); test('real signed ownership proof binds account, source, version and mint nonce', async () => { const signer = new InMemorySigner(b58cencode(randomBytes(32), prefix.edsk2)); @@ -105,11 +115,46 @@ test('wallet ownership is required, metadata immutable and mint dispatch happens const metadata = JSON.parse(f.pins.find(p => p.mime === 'application/json').bytes); assert.equal(metadata.whistlegraph.version, 7); assert.equal(metadata.whistlegraph.sourceHash, f.input.sourceHash); assert.deepEqual(metadata.creators, [address]); + assert.deepEqual(metadata.formats.map(f => f.mimeType), [TEIA_FORMAT, 'image/gif', 'image/png']); + assert.notEqual(metadata.thumbnailUri, metadata.displayUri); const first = await f.mints.begin(f.secret); assert.deepEqual(first.operation, mintOperation([...f.intents.docs.values()][0])); assert.equal(first.operation.destination, HEN_MINTER); assert.equal(first.operation.amount, '0'); await assert.rejects(f.mints.begin(f.secret), /already pending/); }); +test('missing previews and legacy HTML-only intents cannot mint again', async () => { + assert.throws(() => teiaPackage('', { frames:1 }), /required/); + const f = await ready(); + const i = [...f.intents.docs.values()][0]; + delete i.artifactMimeType; + await assert.rejects(f.mints.begin(f.secret), /corrected Teia package/); + await assert.rejects(f.mints.bind(f.secret, { address, signature:'valid' }), /corrected Teia package/); + assert.equal(i.status, 'ready'); +}); +test('IPFS packages use the wrapping directory CID, with all three required files', async () => { + const cid = 'Qm' + 'a'.repeat(44); + let seen; + const server = createServer(async (req,res) => { + let body = ''; for await (const part of req) body += part; + if (req.url.startsWith('/api/v0/add')) { + seen = { url:req.url, body }; + res.end(JSON.stringify({ Name:'index.html', Hash:'Qm' + 'b'.repeat(44) }) + '\n' + JSON.stringify({ Name:'', Hash:cid }) + '\n'); + } else res.end('{}'); + }); + await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); + const old = { api:process.env.IPFS_API_URL, seed:process.env.IPFS_SEEDER_URL }; + process.env.IPFS_API_URL = process.env.IPFS_SEEDER_URL = 'http://127.0.0.1:' + server.address().port; + try { + const { files } = teiaPackage('', { gif:Buffer.from('gif'), thumbnail:Buffer.from('png'), frames:48 }); + assert.equal(await pinMintDirectory(files), 'ipfs://' + cid); + assert.match(seen.url, /wrap-with-directory=true/); + for (const name of ['index.html','cover.gif','thumbnail.png']) assert.ok(seen.body.includes(`filename="${name}"`)); + await assert.rejects(pinMintDirectory(files.slice(0,2)), /Incomplete/); + } finally { + for (const [key,value] of [['IPFS_API_URL',old.api],['IPFS_SEEDER_URL',old.seed]]) if (value === undefined) delete process.env[key]; else process.env[key] = value; + await new Promise(resolve => server.close(resolve)); + } +}); test('lost callbacks recover the unique applied mint and wait for chain confirmations and token transfer', async () => { const f = await requested(); assert.equal((await f.mints.confirm(f.secret)).status, 'requested'); diff --git a/system/tests/whistlegraph-preview-browser.mjs b/system/tests/whistlegraph-preview-browser.mjs new file mode 100644 index 0000000000..0edb5cbe59 --- /dev/null +++ b/system/tests/whistlegraph-preview-browser.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import sharp from 'sharp'; +import { renderWhistlegraphPreview } from '../../oven/whistlegraph-preview.mjs'; +let leaks = 0; +const server = createServer((req,res) => { leaks++; res.end('private'); }); +await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); +try { + const html = ``; + const preview = await renderWhistlegraphPreview(html,'2:3', { executablePath:process.env.PUPPETEER_EXECUTABLE_PATH }); + const gif = await sharp(preview.gif,{animated:true}).metadata(); + assert.equal(gif.pages,48);assert.equal(gif.width,320);assert.equal(gif.pageHeight,480);assert.equal(gif.loop,0); + const first=await sharp(preview.gif,{page:0}).raw().toBuffer(); + const later=await sharp(preview.gif,{page:24}).raw().toBuffer(); + assert.notDeepEqual(first,later,'GIF captures live artwork motion'); + const thumb=await sharp(preview.thumbnail).metadata();assert.equal(thumb.format,'png');assert.equal(thumb.height,350); + assert.equal(leaks,0,'packed code cannot access server network'); + console.log('PASS: animated GIF, matching portrait thumbnail, isolated rendering'); +} finally { await new Promise(resolve => server.close(resolve)); } -- 2.51.2