Something went wrong. Try again.
A little atproto app for organizing and posting meme images
Something went wrong. Try again.
9.8 kB · 308 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309#!/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);});