diff --git a/marketing/podcast/bin/daily-token.mjs b/marketing/podcast/bin/daily-token.mjs index 179394c7da..f07dfe1bf9 100644 --- a/marketing/podcast/bin/daily-token.mjs +++ b/marketing/podcast/bin/daily-token.mjs @@ -4,7 +4,10 @@ // page — the episode as a Star Wars crawl in AC's pixel font: a GIF drawn // here (lib/crawl.mjs), and the same crawl as a live KidLisp // $code on AC; the thumbnail is one of the GIF's frames -// pin — GIF, thumb and TZIP-21 metadata to AC's IPFS node (/api/ipfs-add) +// bundle — the $code packed as a Keep is (oven/bundler.mjs, PACK mode): +// one self-extracting HTML that runs offline in objkt's sandbox +// pin — bundle, GIF, thumb and TZIP-21 metadata to AC's IPFS node +// (/api/ipfs-add); the bundle is the artifact, the GIF the display // mint — mint_OBJKT on the hic et nunc minter, signed by aesthetic.tez // list — an objkt ask for the whole edition // @@ -14,12 +17,13 @@ // Usage: // node bin/daily-token.mjs # today's episode // node bin/daily-token.mjs --date 2026-09-26 -// node bin/daily-token.mjs --dry # page + render only; no pin/mint/list +// node bin/daily-token.mjs --dry # page, bundle, metadata; no pin/mint/list // // Secrets come from the environment or --env (repeatable): // AESTHETIC_KEY, AESTHETIC_ADDRESS the signer (must be aesthetic.tez) // AC_TOKEN or ~/.ac-token an @jeffrey AC session, for /api/ipfs-add -// Tuning: DAILY_EDITIONS (1), DAILY_PRICE_XTZ (3), DAILY_ROYALTIES_PERMILLE (150). +// Tuning: DAILY_EDITIONS (1), DAILY_PRICE_XTZ (3), DAILY_ROYALTIES_PERMILLE (150), +// DAILY_ARTIFACT (html | gif: what the artifactUri is; see lib/artifact.mjs). import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -69,6 +73,10 @@ const PRICE_XTZ = Number(process.env.DAILY_PRICE_XTZ || 3); const ROYALTIES = Number(process.env.DAILY_ROYALTIES_PERMILLE || 150); // HEN is per-mille const MIN_BALANCE_XTZ = 0.15; // a mint + a listing burn ~0.06 +const { artifactMode, crawlBundle, tokenMetadata } = await import(resolve(ROOT, "lib", "artifact.mjs")); +let ARTIFACT; +try { ARTIFACT = artifactMode(); } catch (err) { console.error(`✗ ${err.message}`); process.exit(1); } + // The AC session: AC_TOKEN if given, else this machine's own ~/.ac-token // (from its own `ac-login`; never a copy from another machine, since Auth0 // rotates refresh tokens), renewed a minute early under the shared lock. @@ -144,7 +152,27 @@ if (!receipt.code) { console.log(` ✓ $${code} · crawl ${frames} frames (${(gifBytes / 1024).toFixed(0)} KB) → out/daily/${slug}.gif`); } -if (flags.dry) { console.log(`✓ dry run: $${receipt.code} rendered; nothing pinned or minted.`); process.exit(0); } +// ── 1b. bundle ─────────────────────────────────────────────────────────── +// Packed from the source the receipt holds, so it is the stored $code byte +// for byte; a receipt from a gif-only night gets its bundle on resume. +const htmlPath = resolve(dailyDir, `${slug}.html`); +if (ARTIFACT === "html" && !existsSync(htmlPath)) { + const html = await crawlBundle(receipt.code, receipt.source); + writeFileSync(htmlPath, html); + receipt.htmlBytes = Buffer.byteLength(html); + save(); + console.log(` ✓ bundled $${receipt.code} → out/daily/${slug}.html (${(receipt.htmlBytes / 1024).toFixed(0)} KB)`); +} + +const metadataFor = (uris) => tokenMetadata({ title, body, date, episodeUrl, code: receipt.code, creator: SIGNER, artifact: ARTIFACT, uris, ac: AC }); + +if (flags.dry) { + const dry = (f) => `ipfs://<${f}>`; + const metadata = metadataFor({ html: dry(`${slug}.html`), gif: dry(`${slug}.gif`), thumb: dry(`${slug}-thumb.png`) }); + writeFileSync(resolve(dailyDir, `${slug}.metadata.json`), JSON.stringify(metadata, null, 2) + "\n"); + console.log(`✓ dry run (${ARTIFACT}): $${receipt.code} rendered; metadata → out/daily/${slug}.metadata.json; nothing pinned or minted.`); + process.exit(0); +} // ── 2. pin ─────────────────────────────────────────────────────────────── // Through AC's own IPFS node (/api/ipfs-add, admin-only) — the Kubo node Keeps @@ -165,25 +193,14 @@ async function pin(payload) { const pinFile = (path, name, mimeType) => pin({ name, mimeType, base64: readFileSync(path).toString("base64") }); if (!receipt.metadataUri) { - const artifactUri = await pinFile(resolve(dailyDir, `${slug}.gif`), `${slug}.gif`, "image/gif"); - const thumbnailUri = await pinFile(resolve(dailyDir, `${slug}-thumb.png`), `${slug}-thumb.png`, "image/png"); - const metadata = { - name: title, - description: `${body}\n\n— Aesthetic Dot Computer, ${date}. Listen: ${episodeUrl}\nThe page as a live KidLisp piece: ${AC}/${receipt.code}`, - tags: ["aesthetic.computer", "kidlisp", "podcast", "devlog", "pixelfont"], - symbol: "OBJKT", - artifactUri, - displayUri: artifactUri, - thumbnailUri, - creators: [SIGNER], - formats: [{ uri: artifactUri, mimeType: "image/gif" }], - decimals: 0, - isBooleanAmount: false, - shouldPreferSymbol: false, - date: new Date(`${date}T20:30:00-04:00`).toISOString(), + const uris = { + html: ARTIFACT === "html" ? await pinFile(htmlPath, `${slug}.html`, "text/html") : undefined, + gif: await pinFile(resolve(dailyDir, `${slug}.gif`), `${slug}.gif`, "image/gif"), + thumb: await pinFile(resolve(dailyDir, `${slug}-thumb.png`), `${slug}-thumb.png`, "image/png"), }; + const metadata = metadataFor(uris); const metadataUri = await pin({ name: `${slug}.json`, json: metadata }); - Object.assign(receipt, { artifactUri, thumbnailUri, metadataUri }); + Object.assign(receipt, { artifact: ARTIFACT, artifactUri: metadata.artifactUri, displayUri: metadata.displayUri, thumbnailUri: metadata.thumbnailUri, metadataUri }); save(); console.log(` ✓ pinned ${receipt.metadataUri}`); } diff --git a/marketing/podcast/lib/artifact.mjs b/marketing/podcast/lib/artifact.mjs new file mode 100644 index 0000000000..e891f282eb --- /dev/null +++ b/marketing/podcast/lib/artifact.mjs @@ -0,0 +1,57 @@ +// artifact.mjs — the daily token's live artifact and its TZIP-21 metadata. +// +// The crawl's KidLisp $code, packed by the oven's own bundler exactly as a +// Keep is: one self-extracting text/html file in PACK mode (the runtime, fonts +// and source inlined as a VFS; fetch() answered from it), so it runs in +// objkt's sandbox with no network. The GIF stays on as the displayUri, for +// wallets and feeds that don't run HTML. + +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", "..", ".."); + +export const ARTIFACTS = ["html", "gif"]; + +// DAILY_ARTIFACT=html|gif; anything else is a mistake worth stopping for. +export function artifactMode(env = process.env) { + const mode = (env.DAILY_ARTIFACT || "html").trim().toLowerCase(); + if (!ARTIFACTS.includes(mode)) throw new Error(`DAILY_ARTIFACT must be ${ARTIFACTS.join(" or ")}, not "${mode}"`); + return mode; +} + +// The crawl pins its screen to the GIF's 512², which AC letterboxes by +// offsetting its wrapper. A pack carries no style.css, so the wrapper is +// given the position AC's stylesheet would, or it sits in the top-left. +export const CRAWL_STYLE = "#aesthetic-computer { position: relative; overflow: hidden; }"; + +// The bundler reads AC_SOURCE_DIR when it loads, so the repo's own runtime is +// named first. Its deps (terser, mongodb, sharp) live in oven/node_modules. +export async function crawlBundle(code, source) { + process.env.AC_SOURCE_DIR ||= resolve(REPO, "system", "public", "aesthetic.computer"); + const { createBundleFromSource } = await import(resolve(REPO, "oven", "bundler.mjs")); + const { html } = await createBundleFromSource(code, source, { authorHandle: "jeffrey", style: CRAWL_STYLE }); + return html; +} + +export function tokenMetadata({ title, body, date, episodeUrl, code, creator, artifact = "html", uris, ac = "https://aesthetic.computer", size = 512 }) { + const gif = { uri: uris.gif, mimeType: "image/gif", dimensions: { value: `${size}x${size}`, unit: "px" } }; + const html = { uri: uris.html, mimeType: "text/html", dimensions: { value: "responsive", unit: "viewport" } }; + const live = artifact === "html"; + return { + name: title, + description: `${body}\n\n— Aesthetic Dot Computer, ${date}. Listen: ${episodeUrl}\nThe page as a live KidLisp piece: ${ac}/${code}`, + tags: ["aesthetic.computer", "kidlisp", "podcast", "devlog", "pixelfont"], + symbol: "OBJKT", + artifactUri: live ? uris.html : uris.gif, + displayUri: uris.gif, + thumbnailUri: uris.thumb, + creators: [creator], + formats: live ? [html, gif] : [{ uri: uris.gif, mimeType: "image/gif" }], + decimals: 0, + isBooleanAmount: false, + shouldPreferSymbol: false, + date: new Date(`${date}T20:30:00-04:00`).toISOString(), + }; +} diff --git a/marketing/podcast/test/daily-artifact.test.mjs b/marketing/podcast/test/daily-artifact.test.mjs new file mode 100644 index 0000000000..07b6b7019a --- /dev/null +++ b/marketing/podcast/test/daily-artifact.test.mjs @@ -0,0 +1,90 @@ +// The daily token's live artifact: the switch, the metadata, the bundle. +// node --test marketing/podcast/test/daily-artifact.test.mjs + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; +import { artifactMode, tokenMetadata, crawlBundle, CRAWL_STYLE } from "../lib/artifact.mjs"; +import { crawlLayout, crawlPiece } from "../lib/crawl.mjs"; + +const SIGNER = "tz1gkf8EexComFBJvjtT1zdsisdah791KwBE"; +const uris = { html: "ipfs://QmHtml", gif: "ipfs://QmGif", thumb: "ipfs://QmThumb" }; +const episode = { + title: "a door; (for letters)", + body: `It said "hello", then (quietly) left; a colon: fine?`, + date: "2026-09-28", + episodeUrl: "https://www.buzzsprout.com/2628235", + code: "dly", + creator: SIGNER, + uris, +}; + +test("the switch: html by default, gif on request, anything else refused", () => { + assert.equal(artifactMode({}), "html"); + assert.equal(artifactMode({ DAILY_ARTIFACT: "gif" }), "gif"); + assert.equal(artifactMode({ DAILY_ARTIFACT: " HTML " }), "html"); + assert.throws(() => artifactMode({ DAILY_ARTIFACT: "svg" }), /html or gif/); +}); + +test("html metadata: the bundle is the artifact, the GIF the display", () => { + const m = tokenMetadata({ ...episode, artifact: "html" }); + assert.equal(m.artifactUri, uris.html); + assert.equal(m.displayUri, uris.gif); + assert.equal(m.thumbnailUri, uris.thumb); + // The HTML format entry is a Keep's, field for field. + assert.deepEqual(m.formats, [ + { uri: uris.html, mimeType: "text/html", dimensions: { value: "responsive", unit: "viewport" } }, + { uri: uris.gif, mimeType: "image/gif", dimensions: { value: "512x512", unit: "px" } }, + ]); + assert.deepEqual(m.creators, [SIGNER]); + assert.equal(m.decimals, 0); + assert.equal(m.symbol, "OBJKT"); + assert.ok(m.description.startsWith(episode.body)); + assert.equal(m.date, "2026-09-29T00:30:00.000Z"); +}); + +test("gif metadata is exactly what the daily minted before the switch", () => { + const m = tokenMetadata({ ...episode, artifact: "gif" }); + assert.deepEqual(m, { + name: episode.title, + description: `${episode.body}\n\n— Aesthetic Dot Computer, ${episode.date}. Listen: ${episode.episodeUrl}\nThe page as a live KidLisp piece: https://aesthetic.computer/dly`, + tags: ["aesthetic.computer", "kidlisp", "podcast", "devlog", "pixelfont"], + symbol: "OBJKT", + artifactUri: uris.gif, + displayUri: uris.gif, + thumbnailUri: uris.thumb, + creators: [SIGNER], + formats: [{ uri: uris.gif, mimeType: "image/gif" }], + decimals: 0, + isBooleanAmount: false, + shouldPreferSymbol: false, + date: "2026-09-29T00:30:00.000Z", + }); +}); + +const layout = crawlLayout({ title: episode.title, body: episode.body, date: episode.date }); +const source = crawlPiece(layout); + +test("the crawl pins its screen to the GIF and keeps its punctuation", () => { + assert.ok(source.startsWith("(resolution 512 512 1)\n(wipe black)")); + assert.ok(source.includes(`(write "A DOOR; (FOR LETTERS)"`)); + const written = [...source.matchAll(/\(write "((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]).join(" "); + assert.equal(written, `A DOOR; (FOR LETTERS) It said \\"hello\\", then (quietly) left; a colon: fine?`); +}); + +test("the bundle is one offline, self-extracting PACK-mode page", async () => { + const outer = await crawlBundle("dly", source); + assert.ok(Buffer.byteLength(outer) < 2 * 1024 * 1024, "under 2 MB"); + assert.doesNotMatch(outer, /\s(src|href)=["']?https?:/i, "no external loads in the shell"); + const b64 = outer.match(/const b64='([A-Za-z0-9+/=]+)'/)[1]; + const inner = gunzipSync(Buffer.from(b64, "base64")).toString("utf8"); + assert.match(inner, /window\.acPACK_MODE = true;/); + assert.match(inner, /window\.acSTARTING_PIECE = "\$dly";/); + assert.ok(inner.includes(`window.acKIDLISP_SOURCE = ${JSON.stringify(source)};`), "the exact source"); + assert.ok(inner.includes(CRAWL_STYLE), "the wrapper style"); + assert.doesNotMatch(inner, /\s(src|href)=["']?https?:/i, "no external loads in the page"); + const vfs = JSON.parse(inner.match(/window\.VFS = (\{.*?\});\n/s)[1].replace(/<\\\/script>/g, "")); + for (const f of ["boot.mjs", "bios.mjs", "lib/disk.mjs", "lib/kidlisp.mjs", "disks/dly.lisp"]) assert.ok(vfs[f], `VFS has ${f}`); + assert.ok(Object.keys(vfs).some((f) => f.startsWith("disks/drawings/font_1/")), "font_1 glyphs inlined"); + assert.equal(vfs["disks/dly.lisp"].content, source); +});