From de7ba311ea414e88f7f9fd0e8ad3952e83292fab Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Sat, 18 Jul 2026 20:27:41 +0000 Subject: [PATCH] Add Kunaki cassette production tools --- marketing/podcast/KUNAKI.md | 37 +++++++++++++++++++++++++++++++++++++ marketing/podcast/bin/kunaki-cassette-kit.mjs | 43 +++++++++++++++++++++++++++++++++++++++++++ marketing/podcast/bin/kunaki.mjs | 16 ++++++++++++++++ marketing/podcast/lib/kunaki.mjs | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ spec/kunaki-spec.mjs | 22 ++++++++++++++++++++++ 6 file(s) changed, 224 insertion(s)(+), 0 deletion(s)(-) diff --git a/marketing/podcast/KUNAKI.md b/marketing/podcast/KUNAKI.md new file mode 100644 --- /dev/null +++ b/marketing/podcast/KUNAKI.md @@ -0,0 +1,37 @@ +# Kunaki cassette production + +Kunaki separates product creation from fulfillment: + +1. Generate a cassette kit locally. +2. Create the cassette in Kunaki's browser uploader and inspect its virtual proof. +3. Record the resulting 10-character product ID. +4. Use the API client for shipping quotes, test orders, live orders, and tracking. + +```sh +npm run podcast:cassette-kit -- --slug physical-mail --title "A Record in the Mail" +npm run podcast:kunaki -- specs +npm run podcast:kunaki -- shipping shipping.json +KUNAKI_USER_ID=... KUNAKI_PASSWORD=... npm run podcast:kunaki -- order order.json +``` + +An order defaults to Kunaki's `Test` mode. Live order construction additionally +requires `KUNAKI_ALLOW_LIVE=1`; this prevents an accidental manufacturing order. +Do not commit credentials or recipients' addresses. + +Example shipping input: + +```json +{ + "country": "United States", + "stateProvince": "CA", + "postalCode": "90012", + "items": [{ "productId": "PX0012345", "quantity": 1 }] +} +``` + +Example order input adds `recipient` and the exact `shippingDescription` returned +by the quote. Product creation cannot be automated by Kunaki's fulfillment API. + +Current cassette artwork requirements are encoded in `lib/kunaki.mjs`: JPEG at +300 DPI, 1200×1110 for the J-card, and 1062×496 for each side label. Kunaki says +bleed is unnecessary but recommends keeping text and lines clear of the edges. diff --git a/marketing/podcast/bin/kunaki-cassette-kit.mjs b/marketing/podcast/bin/kunaki-cassette-kit.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/bin/kunaki-cassette-kit.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { CASSETTE_SPECS } from "../lib/kunaki.mjs"; + +const args = Object.fromEntries(process.argv.slice(2).map((arg, i, all) => arg.startsWith("--") ? [arg.slice(2), all[i + 1]?.startsWith("--") ? true : all[i + 1]] : null).filter(Boolean)); +const slug = args.slug || "physical-mail"; +const title = args.title || "A Record in the Mail"; +const subtitle = args.subtitle || "Aesthetic Computer · read by @jeffrey"; +const cover = resolve(args.cover || `marketing/podcast/out/${slug}-cover.png`); +const audio = resolve(args.audio || `marketing/podcast/out/${slug}.mp3`); +const out = resolve(args.out || `marketing/podcast/out/${slug}-cassette`); +if (!existsSync(cover)) throw new Error(`Missing cover: ${cover}`); +if (!existsSync(audio)) throw new Error(`Missing audio: ${audio}`); +mkdirSync(out, { recursive: true }); + +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const svg = (width, height, side, extra = "") => `AESTHETIC.COMPUTER · CASSETTE ${side}${esc(title)}${esc(subtitle)}${extra}`; + +const render = (name, spec, side, extra = "") => { + const svgPath = resolve(out, `.${name}.svg`); + const pngPath = resolve(out, `.${name}.png`); + const jpgPath = resolve(out, `${name}.jpg`); + writeFileSync(svgPath, svg(spec.width, spec.height, side, extra)); + execFileSync("rsvg-convert", ["--width", String(spec.width), "--height", String(spec.height), "--output", pngPath, svgPath]); + execFileSync("magick", [pngPath, "-units", "PixelsPerInch", "-density", "300", "-quality", "95", jpgPath]); + return jpgPath; +}; + +const jCard = resolve(out, "j-card.jpg"); +execFileSync("magick", [cover, "-resize", "1200x1110^", "-gravity", "center", "-extent", "1200x1110", "-units", "PixelsPerInch", "-density", "300", "-quality", "95", jCard]); +const labelA = render("label-a", CASSETTE_SPECS.artwork.labelA, "SIDE A", `THE ESSAY + LISTENER MAIL INVITATION`); +const labelB = render("label-b", CASSETTE_SPECS.artwork.labelB, "SIDE B", `LETTERS · mail@aesthetic.computer`); + +const duration = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", audio], { encoding: "utf8" }).trim()); +if (duration > CASSETTE_SPECS.audio.maxMinutesPerSide * 60) throw new Error(`Audio is ${(duration / 60).toFixed(1)} minutes; maximum is 40 minutes per side`); +const sideA = resolve(out, "side-a.wav"); +execFileSync("ffmpeg", ["-y", "-i", audio, "-ar", "44100", "-ac", "2", sideA], { stdio: "ignore" }); +const manifest = { vendor: "kunaki", productType: "cassette", title, sourceAudio: basename(audio), durationSeconds: duration, files: { sideA: basename(sideA), jCard: basename(jCard), labelA: basename(labelA), labelB: basename(labelB) }, specs: CASSETTE_SPECS, note: "Kunaki product creation remains a manual browser upload; the API begins after a product ID exists." }; +writeFileSync(resolve(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +writeFileSync(resolve(out, "README.md"), `# ${title} — Kunaki cassette kit\n\nUpload \`side-a.wav\` to side A. Leave side B silent until its program is chosen. Upload \`j-card.jpg\`, \`label-a.jpg\`, and \`label-b.jpg\` in Kunaki's browser product creator. After publishing, record the 10-character product ID in \`manifest.json\`; fulfillment can then use \`bin/kunaki.mjs\`.\n\nArtwork: JPEG, 300 DPI, no bleed. J-card 1200×1110; labels 1062×496. Keep important text clear of edges.\n`); +console.log(out); diff --git a/marketing/podcast/bin/kunaki.mjs b/marketing/podcast/bin/kunaki.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/bin/kunaki.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { CASSETTE_SPECS, orderUrl, request, shippingOptionsUrl, statusUrl } from "../lib/kunaki.mjs"; + +const [command = "specs", file] = process.argv.slice(2); +const input = file ? JSON.parse(readFileSync(file, "utf8")) : {}; +const credentials = { userId: process.env.KUNAKI_USER_ID, password: process.env.KUNAKI_PASSWORD }; + +let result; +if (command === "specs") result = CASSETTE_SPECS; +else if (command === "shipping") result = await request(shippingOptionsUrl(input)); +else if (command === "order") result = await request(orderUrl({ ...input, credentials, mode: input.mode || "Test" })); +else if (command === "status") result = await request(statusUrl({ credentials, orderId: input.orderId })); +else throw new Error("Usage: kunaki.mjs specs | shipping input.json | order input.json | status input.json"); + +console.log(JSON.stringify(result, null, 2)); diff --git a/marketing/podcast/lib/kunaki.mjs b/marketing/podcast/lib/kunaki.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/lib/kunaki.mjs @@ -0,0 +1,104 @@ +const ENDPOINT = "https://Kunaki.com/HTTPService.ASP"; + +export const CASSETTE_SPECS = Object.freeze({ + source: "https://kunaki.com/product-cassette.html", + checked: "2026-07-18", + priceUsd: 5, + manufactureHours: 24, + audio: { + maxMinutesPerSide: 40, + accepted: ["wav", "mp3", "aac", "wmv", "m4a"], + }, + artwork: { + format: "jpeg", + dpi: 300, + bleedRequired: false, + jCard: { width: 1200, height: 1110 }, + labelA: { width: 1062, height: 496 }, + labelB: { width: 1062, height: 496 }, + }, +}); + +const required = (value, name) => { + if (value === undefined || value === null || value === "") throw new Error(`Missing ${name}`); + return String(value); +}; + +function products(params, items) { + if (!Array.isArray(items) || items.length === 0) throw new Error("At least one product is required"); + for (const [index, item] of items.entries()) { + params.append("ProductId", required(item.productId, `items[${index}].productId`)); + params.append("Quantity", required(item.quantity ?? 1, `items[${index}].quantity`)); + } +} + +export function shippingOptionsUrl({ country, stateProvince = "", postalCode, items }) { + const params = new URLSearchParams({ + RequestType: "ShippingOptions", + State_Province: stateProvince, + PostalCode: required(postalCode, "postalCode"), + Country: required(country, "country"), + ResponseType: "xml", + }); + products(params, items); + return `${ENDPOINT}?${params}`; +} + +export function orderUrl({ credentials, recipient, shippingDescription, items, mode = "Test" }) { + if (String(mode).toLowerCase() === "live" && process.env.KUNAKI_ALLOW_LIVE !== "1") { + throw new Error("Live Kunaki orders require KUNAKI_ALLOW_LIVE=1"); + } + const params = new URLSearchParams({ + RequestType: "Order", + UserId: required(credentials?.userId, "credentials.userId"), + Password: required(credentials?.password, "credentials.password"), + Mode: mode, + Name: required(recipient?.name, "recipient.name"), + Company: recipient?.company || "", + Address1: required(recipient?.address1, "recipient.address1"), + Address2: recipient?.address2 || "", + City: required(recipient?.city, "recipient.city"), + State_Province: recipient?.stateProvince || "", + PostalCode: required(recipient?.postalCode, "recipient.postalCode"), + Country: required(recipient?.country, "recipient.country"), + ShippingDescription: required(shippingDescription, "shippingDescription"), + ResponseType: "xml", + }); + products(params, items); + return `${ENDPOINT}?${params}`; +} + +export function statusUrl({ credentials, orderId }) { + return `${ENDPOINT}?${new URLSearchParams({ + RequestType: "OrderStatus", + UserId: required(credentials?.userId, "credentials.userId"), + Password: required(credentials?.password, "credentials.password"), + OrderId: required(orderId, "orderId"), + ResponseType: "xml", + })}`; +} + +const entity = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'"); +const tags = (xml, name) => [...String(xml).matchAll(new RegExp(`<\\s*${name}\\s*>([\\s\\S]*?)<\\s*\\/\\s*${name}\\s*>`, "gi"))].map((m) => entity(m[1].trim())); + +export function parseResponse(xml) { + const errorCode = tags(xml, "ErrorCode")[0]; + const result = { errorCode: Number(errorCode), errorText: tags(xml, "ErrorText")[0] || "" }; + if (!Number.isFinite(result.errorCode)) throw new Error("Kunaki returned an unreadable response"); + if (result.errorCode !== 0) throw new Error(`Kunaki ${result.errorCode}: ${result.errorText}`); + const descriptions = tags(xml, "Description"); + const times = tags(xml, "DeliveryTime"); + const prices = tags(xml, "Price"); + if (descriptions.length) result.options = descriptions.map((description, i) => ({ description, deliveryTime: times[i], priceUsd: Number(prices[i]) })); + for (const name of ["OrderId", "OrderStatus", "TrackingType", "TrackingId"]) { + const value = tags(xml, name)[0]; + if (value !== undefined) result[name[0].toLowerCase() + name.slice(1)] = value; + } + return result; +} + +export async function request(url, fetchImpl = fetch) { + const response = await fetchImpl(url, { headers: { accept: "application/xml,text/xml" } }); + if (!response.ok) throw new Error(`Kunaki HTTP ${response.status}`); + return parseResponse(await response.text()); +} diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -99,6 +99,8 @@ "pop:assets:up": "aws s3 sync system/public/assets/pop s3://assets-aesthetic-computer/pop --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --acl public-read", "podcast:feed": "node marketing/podcast/bin/feed.mjs", "podcast:publish": "node marketing/podcast/bin/publish.mjs", "podcast:publish:push": "node marketing/podcast/bin/publish.mjs --push", + "podcast:kunaki": "node marketing/podcast/bin/kunaki.mjs", + "podcast:cassette-kit": "node marketing/podcast/bin/kunaki-cassette-kit.mjs", "essay:reel": "node marketing/essay-reels/bin/render.mjs", "thespianjas:generate": "node thespianjas/bin/generate.mjs", "thespianjas:studio": "node thespianjas/bin/serve.mjs", diff --git a/spec/kunaki-spec.mjs b/spec/kunaki-spec.mjs new file mode 100644 --- /dev/null +++ b/spec/kunaki-spec.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CASSETTE_SPECS, orderUrl, parseResponse, shippingOptionsUrl } from "../marketing/podcast/lib/kunaki.mjs"; + +describe("Kunaki integration", () => { + it("encodes duplicate product fields in shipping quotes", () => { + const url = new URL(shippingOptionsUrl({ country: "United States", stateProvince: "CA", postalCode: "90012", items: [{ productId: "PX0012345", quantity: 2 }] })); + assert.equal(url.searchParams.get("RequestType"), "ShippingOptions"); + assert.deepEqual(url.searchParams.getAll("ProductId"), ["PX0012345"]); + }); + it("parses shipping XML", () => { + const parsed = parseResponse("0success"); + assert.deepEqual(parsed.options, [{ description: "USPS", deliveryTime: "2-5 days", priceUsd: 5.25 }]); + }); + it("guards live orders", () => { + assert.throws(() => orderUrl({ mode: "Live", credentials: { userId: "x", password: "y" }, recipient: {}, shippingDescription: "USPS", items: [{ productId: "PX0012345" }] }), /KUNAKI_ALLOW_LIVE/); + }); + it("tracks current cassette artwork dimensions", () => { + assert.deepEqual(CASSETTE_SPECS.artwork.jCard, { width: 1200, height: 1110 }); + assert.deepEqual(CASSETTE_SPECS.artwork.labelA, { width: 1062, height: 496 }); + }); +}); -- tangled.sh