#!/usr/bin/env node // Seeds alice.devnet.test's library with placeholder memes + collections for // local dev/demo purposes. Placeholder images are solid-color PNGs/APNGs // (not real meme content, for obvious copyright reasons) labeled with // well-known meme names so the library has something realistic to browse. // // Uses direct password auth (com.atproto.server.createSession) rather than // OAuth -- fine for a self-hosted devnet test account, not something this // app does for real ATProto accounts. // // Usage: node scripts/seed-devnet.mjs import { AtpAgent } from '@atproto/api'; import zlib from 'node:zlib'; const PDS_URL = 'http://localhost:3000'; const HANDLE = 'alice.devnet.test'; const PASSWORD = 'alice-devnet-pass'; const MEME_NSID = 'at.meme-museum.meme'; const COLLECTION_NSID = 'at.meme-museum.collection'; const COLLECTION_ITEM_NSID = 'at.meme-museum.collectionItem'; // ── Minimal PNG/APNG encoder (no deps) ───────────────────────────────────── const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; } return table; })(); function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const typeBuf = Buffer.from(type, 'ascii'); const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); return Buffer.concat([len, typeBuf, data, crcBuf]); } function u32(n) { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0, 0); return b; } function u16(n) { const b = Buffer.alloc(2); b.writeUInt16BE(n, 0); return b; } const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); function solidScanlines(width, height, [r, g, b]) { const row = Buffer.alloc(1 + width * 3); for (let x = 0; x < width; x++) { row[1 + x * 3] = r; row[1 + x * 3 + 1] = g; row[1 + x * 3 + 2] = b; } return Buffer.concat(Array(height).fill(row)); } function ihdr(width, height) { const data = Buffer.concat([u32(width), u32(height), Buffer.from([8, 2, 0, 0, 0])]); return chunk('IHDR', data); } /** A single solid-color still PNG. */ function makePng(width, height, rgb) { const raw = solidScanlines(width, height, rgb); const idat = zlib.deflateSync(raw); return Buffer.concat([PNG_SIG, ihdr(width, height), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]); } /** An animated PNG cycling through solid-color frames. */ function makeApng(width, height, frames, delayMs = 400) { let seq = 0; const parts = [PNG_SIG, ihdr(width, height)]; parts.push(chunk('acTL', Buffer.concat([u32(frames.length), u32(0)]))); frames.forEach((rgb, i) => { const fcTLData = Buffer.concat([ u32(seq++), u32(width), u32(height), u32(0), u32(0), u16(delayMs), u16(1000), Buffer.from([0, 0]), ]); parts.push(chunk('fcTL', fcTLData)); const raw = solidScanlines(width, height, rgb); const compressed = zlib.deflateSync(raw); if (i === 0) { parts.push(chunk('IDAT', compressed)); } else { parts.push(chunk('fdAT', Buffer.concat([u32(seq++), compressed]))); } }); parts.push(chunk('IEND', Buffer.alloc(0))); return Buffer.concat(parts); } // ── Seed data ─────────────────────────────────────────────────────────────── const SIZE = 240; const stillMemes = [ { title: 'Distracted Boyfriend', alt: 'Placeholder swatch standing in for the distracted boyfriend meme', tags: ['reaction', 'classic'], rgb: [196, 64, 64], collection: 'Reactions', }, { title: 'Drake Hotline Bling', alt: 'Placeholder swatch standing in for the Drake approval/disapproval meme', tags: ['reaction', 'choice'], rgb: [64, 96, 196], collection: 'Reactions', }, { title: 'Woman Yelling at Cat', alt: 'Placeholder swatch standing in for the woman yelling at a cat meme', tags: ['reaction', 'argument'], rgb: [144, 64, 196], collection: 'Reactions', }, { title: 'Success Kid', alt: 'Placeholder swatch standing in for the success kid meme', tags: ['reaction', 'celebration'], rgb: [76, 160, 76], collection: 'Reactions', }, { title: 'Blinking White Guy', alt: 'Placeholder swatch standing in for the confused blinking guy meme', tags: ['reaction', 'confused'], rgb: [212, 176, 42], collection: null, visibility: 'unlisted', }, { title: 'MJ Eating Popcorn', alt: 'Placeholder swatch standing in for Michael Jackson eating popcorn', tags: ['reaction', 'watching', 'evergreen'], rgb: [212, 128, 42], collection: 'Favorites', }, ]; /** * Smooth brightness "breathing" cycle around a base color -- many small * steps, sinusoidal easing -- rather than a couple of harshly-contrasting * frames. Reads as a gentle pulse instead of a blink. */ function pulseFrames(baseRgb, { steps = 16, minFactor = 0.6, maxFactor = 1.0 } = {}) { return Array.from({ length: steps }, (_, i) => { const phase = (i / steps) * Math.PI * 2; const factor = minFactor + (maxFactor - minFactor) * (0.5 + 0.5 * Math.sin(phase)); return baseRgb.map((c) => Math.round(c * factor)); }); } const animatedMemes = [ { title: 'Loading Forever', alt: 'Placeholder pulsing animation standing in for a spinning loading meme', tags: ['animated', 'loading'], frames: pulseFrames([130, 130, 130], { steps: 16, minFactor: 0.5, maxFactor: 1.0 }), frameDelayMs: 60, collection: 'Animated', }, { title: 'Excited Clapping', alt: 'Placeholder pulsing animation standing in for excited clapping', tags: ['animated', 'celebration'], frames: pulseFrames([224, 168, 40], { steps: 16, minFactor: 0.65, maxFactor: 1.0 }), frameDelayMs: 60, collection: 'Animated', visibility: 'unlisted', }, ]; const collectionDefs = [ { name: 'Reactions', description: 'Everyday reaction images.', visibility: 'public' }, { name: 'Animated', description: 'Loops and GIF-likes.', visibility: 'public' }, { name: 'Favorites', description: 'The ones I reach for constantly.', visibility: 'unlisted' }, ]; // ── Run ────────────────────────────────────────────────────────────────────── async function main() { const agent = new AtpAgent({ service: PDS_URL }); await agent.login({ identifier: HANDLE, password: PASSWORD }); const did = agent.assertDid; console.log(`Logged in as ${HANDLE} (${did})`); function rkey() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } async function uploadBlob(bytes) { const resp = await agent.com.atproto.repo.uploadBlob(bytes, { encoding: 'image/png' }); return resp.data.blob; } async function createMeme({ title, alt, tags, visibility, image, isAnimated }) { const record = { $type: MEME_NSID, image, alt, isAnimated, width: SIZE, height: SIZE, title, tags, visibility: visibility ?? 'public', createdAt: new Date().toISOString(), }; const resp = await agent.com.atproto.repo.putRecord({ repo: did, collection: MEME_NSID, rkey: rkey(), record, }); console.log(` meme: ${title} -> ${resp.data.uri}`); return { uri: resp.data.uri, cid: resp.data.cid }; } async function createCollection({ name, description, visibility }) { const record = { $type: COLLECTION_NSID, name, description, visibility, createdAt: new Date().toISOString(), }; const resp = await agent.com.atproto.repo.putRecord({ repo: did, collection: COLLECTION_NSID, rkey: rkey(), record, }); console.log(`collection: ${name} -> ${resp.data.uri}`); return { uri: resp.data.uri, cid: resp.data.cid }; } async function addToCollection(collectionRef, memeRef) { const record = { $type: COLLECTION_ITEM_NSID, collection: { uri: collectionRef.uri, cid: collectionRef.cid }, meme: { uri: memeRef.uri, cid: memeRef.cid }, createdAt: new Date().toISOString(), }; await agent.com.atproto.repo.putRecord({ repo: did, collection: COLLECTION_ITEM_NSID, rkey: rkey(), record, }); } console.log('\nCreating collections...'); const collectionsByName = {}; for (const def of collectionDefs) { collectionsByName[def.name] = await createCollection(def); } console.log('\nCreating still memes...'); for (const m of stillMemes) { const png = makePng(SIZE, SIZE, m.rgb); const blob = await uploadBlob(png); const memeRef = await createMeme({ ...m, image: blob, isAnimated: false }); if (m.collection) await addToCollection(collectionsByName[m.collection], memeRef); } console.log('\nCreating animated memes...'); for (const m of animatedMemes) { const apng = makeApng(SIZE, SIZE, m.frames, m.frameDelayMs ?? 400); const blob = await uploadBlob(apng); const memeRef = await createMeme({ ...m, image: blob, isAnimated: true }); if (m.collection) await addToCollection(collectionsByName[m.collection], memeRef); } console.log('\nDone.'); } main().catch((err) => { console.error(err); process.exit(1); });