/** * The Learn library: the PIG-hosted recordings. * * There used to be two illustrative rows here as well, embedding public * recordings from the Cap instance at video.karti.ai under `DEMO — ` titles, so * that the `supply` and `demand` concept tracks were not empty. Both are gone, * for two reasons that arrived together. * * One of the two recordings had since been removed from that instance: * `sjqqvthbfma27bm` answers 404 on both `/s/` and `/embed/`, so the card * promised six seconds of teaching and played a dead frame. That is the exact * failure the previous version of this comment was written to record — an id * that 404s behind a card that claims content — and it came back because the * footage lives somewhere this repository does not control. * * The other is judgement: /learn is the page deliberately shown to outsiders, * and a `DEMO — ` card sitting beside genuine product footage makes the whole * page read as half-placeholder to exactly the audience it exists to convince. * An empty concept track renders a proper empty state saying nothing has been * published yet, which is true and says so. * * If `supply` and `demand` get purpose-shot recordings, they belong in * `HOSTED_LEARN_MANIFEST` below and rendered by `scripts/learn-film.mjs` like * the platform track, not embedded from an instance whose contents can change * underneath this file. */ import { LEARN_MEDIA_PATH_PREFIX, isLearnMediaFilename, learnMediaContentType } from '@pig/core'; import { eq } from 'drizzle-orm'; import { readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import { learnResources, users } from '../../schema/index'; import type { DemoContext } from './index'; // ------------------------------------------------------- PIG-hosted learn // // Real videos, served by PIG itself from PIG_MEDIA_DIR — not demo data. They // carry no `DEMO — ` prefix precisely because they are genuine product // walkthroughs, which also means `--clear` leaves them alone; `--clear-hosted` // is their own switch. // // **A row is written only when its file is actually on disk.** A learn row // whose media 404s is worse than a missing row: the card renders, the play // button does nothing, and the feature reads as broken. So the manifest below // declares the curriculum, and the seed inserts the entries it can find. // Running it before the videos are generated is a no-op with a printed list, // and running it again afterwards fills them in. // // That choice was re-examined, because the audience for the absence is not an // operator: the anonymous share-code view of /learn shows this track and // nothing else, so a missing file is a stranger's first impression of the // product. A "coming soon" ROW was rejected. It would need a filename to point // at, the card would carry a play button, and pressing it would fail — which // is the one outcome worse than an empty section, and the same reason the // access hero draws redacted bars rather than invented thumbnails. The absence // is handled where it belongs instead: `apps/web/src/pages/Learn.tsx` gives a // code-holder with nothing published a finished panel that says so and offers // a way on, rather than the admin empty state. // // `media/` is gitignored — hundreds of megabytes of rendered MP4 are a release // artefact, not source — so a fresh clone and every git worktree start without // it, and that is the ordinary case rather than a fault. Point PIG_MEDIA_DIR // at a directory holding the renders (the API route reads the same variable) // and the five rows appear. // // **The filename is discovered, not written down.** Files are // content-addressed — `..mp4` — so the hash changes every time a // video is re-rendered, and a manifest carrying the hash would be a file that // has to be edited in lockstep with a render. Instead the slug is the stable // identity and the directory supplies the rest. Idempotency then rests on the // unique key (track, provider, external_id) exactly as the DEMO rows do. // // A re-render produces a NEW hash and therefore a new row; the old row keeps // pointing at a file that is no longer there. That is reported rather than // resolved automatically, because deleting rows on the strength of a missing // file would empty the curriculum the first time someone ran this with the // media directory unmounted. interface HostedLearnEntry { /** Stable identity. Also the filename stem: `..`. */ slug: string; title: string; summary: string; track: 'supply' | 'demand' | 'platform'; visibility: 'members' | 'code'; /** * Measured from the render, to the nearest second — not estimated from the * script. Every one below was re-checked against the file it names, because * a duration is the only claim a card makes that a viewer can verify before * pressing play. */ durationSeconds: number; sortOrder: number; } export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [ { slug: 'overview-and-margin', title: 'Overview and the margin question', summary: 'Which contracted capacity is sold, at what margin, and what is idle right now — and why cost is charged against the full commitment.', track: 'platform', visibility: 'code', durationSeconds: 29, sortOrder: 1, }, { slug: 'quarterly-calendar', title: 'The quarterly calendar', summary: 'What closes, what renews, what expires and when capacity lands — with export authorisations and compliance artefacts at the top.', track: 'platform', visibility: 'code', durationSeconds: 30, sortOrder: 2, }, { slug: 'capacity-to-allocations', title: 'Capacity to allocations', summary: 'Joining a commitment you bought to a deal you sold — the availability book, the matcher, and the allocation the ledger is built on.', track: 'platform', visibility: 'code', durationSeconds: 26, sortOrder: 3, }, { slug: 'importing-your-book', title: 'Importing your book', summary: 'Getting off the spreadsheet — CSV, Excel, Notion or a bounded Google Sheets range, with a dry run you review before anything is written.', track: 'platform', visibility: 'code', durationSeconds: 28, sortOrder: 4, }, { slug: 'piggy-and-its-boundary', title: 'Piggy, and where it stops', // This said Piggy had "no ability to write CRM records" — the headline // capability of the release it ships beside, denied on the one track a // public share code opens, two clicks from a front door reading "Piggy can // change it, with your approval." The boundary is real and still worth // stating; it is approval, not incapability. summary: 'The docked agent reads through scoped, page-specific PIG tools, and proposes any change as a card that only a person can apply — no shell, no filesystem, and nothing written without a human pressing Apply.', track: 'platform', visibility: 'code', durationSeconds: 28, sortOrder: 5, }, /* * Six and seven are landscape, like the five above, and cut by * `scripts/learn-film.mjs`. A vertical pair was tried first and rejected: * the product is a desktop application, and reframing it 9:16 either shrinks * it past reading or crops away the sidebar and half the table. The camera * holds still and eases in on the figure being named, rather than drifting * across every shot, which is what made the first cut tiring to watch. * * Seven is longer than the rest on purpose — it walks one customer from the * first discovery to a kill gate that may not be met, and that is a story * rather than a feature tour. */ { slug: 'motion-the-repeatable-motion', title: 'Motion: the part of a deal PIG never knew', summary: 'PIG knew every deal we closed. It never knew how we closed it. The library of what we know how to do, bound to the stages of a real deal.', track: 'platform', visibility: 'code', durationSeconds: 47, sortOrder: 6, }, { slug: 'one-customer-end-to-end', title: 'Halcyon Research, and a gate it might miss', summary: 'One customer end to end: scored 52.8 and scope down on discovery day, 82.8 and build once measurement landed, and two dimensions that fell on the way up.', track: 'platform', visibility: 'code', durationSeconds: 68, sortOrder: 7, }, ]; /** * Where the served files live. The same variable the API route reads. * * The default is resolved from this file's location, not from `process.cwd()`, * because pnpm runs the seed with the working directory at `packages/db` while * the server runs at the repository root — so a relative default would mean * two different directories, and the seed would write rows for files the API * cannot find. * * Five levels up, because this file sits one deeper than the seed it was split * out of: `packages/db/src/seed/demo` → repository root. */ function mediaDirectory(): string { const configured = process.env.PIG_MEDIA_DIR?.trim(); if (configured && configured.length > 0) return resolve(configured); return resolve(import.meta.dirname, '../../../../../media'); } /** * Find the one VIDEO belonging to a slug. * * Two videos for one slug is an error rather than a choice: picking the newest * would silently publish whichever render happened to finish last, and the * operator has an old file to delete. * * Video extensions only, and that is the whole point of the second predicate. * A poster is `..jpg` — deliberately the video's own stem, so it * cannot go stale against the clip it shows (`learnPosterFilename`) — and it * sits in this same directory. Matching on the slug alone therefore found two * files for every complete entry and declared each of them an ambiguous stale * render, so a directory containing five finished walkthroughs AND their * posters seeded exactly nothing, and the share-code page it feeds rendered * empty on the one box that had the media. */ function mediaFileFor( slug: string, filenames: readonly string[], ): { filename: string | null; ambiguous: readonly string[] } { const matches = filenames.filter( (name) => isLearnMediaFilename(name) && name.startsWith(`${slug}.`) && learnMediaContentType(name)?.startsWith('video/'), ); /* * Reported and skipped, not thrown. * * This runs from the middle of seedDemo(), so throwing on a duplicate took * out every later section — facts, activities, the lot — and left the * operator working out why `pnpm db:demo` died on two MP4s sharing a slug. * A stale render is a condition of one directory entry; its blast radius * should be that entry. Missing files are already handled this way. */ if (matches.length > 1) return { filename: null, ambiguous: matches }; return { filename: matches[0] ?? null, ambiguous: [] }; } export async function seedHostedLearn(context: DemoContext): Promise<{ present: number; added: number; missing: number; }> { const { db } = context; const directory = mediaDirectory(); let filenames: string[] = []; try { filenames = await readdir(directory); } catch { // No directory is the normal state of a fresh checkout, not a failure. console.log( ` (no media directory at ${directory} — skipping PIG-hosted learn videos; ` + 'set PIG_MEDIA_DIR if the renders live elsewhere)', ); return { present: 0, added: 0, missing: HOSTED_LEARN_MANIFEST.length }; } const [owner] = await db.select({ id: users.id }).from(users).limit(1); let present = 0; let added = 0; const absent: string[] = []; for (const entry of HOSTED_LEARN_MANIFEST) { const { filename, ambiguous } = mediaFileFor(entry.slug, filenames); if (ambiguous.length > 0) { console.warn( ` ! ${entry.slug}: ${ambiguous.length} files match (${ambiguous.join(', ')}) — ` + 'content-addressed names mean one is a stale render. Delete it and re-run. Skipped.', ); absent.push(entry.slug); continue; } if (!filename) { absent.push(entry.slug); continue; } present += 1; const inserted = await db .insert(learnResources) .values({ track: entry.track, title: entry.title, summary: entry.summary, // The same path the resolver builds, from the same constant — so a // seeded row and a row created through the API are indistinguishable. url: `${LEARN_MEDIA_PATH_PREFIX}${filename}`, provider: 'pig', externalId: filename, visibility: entry.visibility, durationSeconds: entry.durationSeconds, sortOrder: entry.sortOrder, addedByUserId: owner?.id ?? null, }) .onConflictDoNothing({ target: [learnResources.track, learnResources.provider, learnResources.externalId], }) .returning({ id: learnResources.id }); if (inserted.length) added += 1; } if (absent.length) { console.log( ` (no file yet in ${directory} for: ${absent.join(', ')} — ` + 'drop ..mp4 there, or point PIG_MEDIA_DIR at the renders, and run this again)', ); } // Rows whose file has gone: reported, never deleted. See the note above. const orphans = ( await db .select({ externalId: learnResources.externalId, title: learnResources.title }) .from(learnResources) .where(eq(learnResources.provider, 'pig')) ).filter((row) => !filenames.includes(row.externalId)); for (const orphan of orphans) { console.log(` ! "${orphan.title}" points at ${orphan.externalId}, which is not on disk`); } return { present, added, missing: absent.length }; } /** * `--clear-hosted`. Kept beside the seed it undoes rather than in `clear.ts`, * because it is the opposite of a different command: `--clear` removes the * invented book and must leave these genuine rows exactly where they are. */ export async function clearHostedLearn(context: DemoContext): Promise { const removed = await context.db .delete(learnResources) .where(eq(learnResources.provider, 'pig')) .returning({ id: learnResources.id }); console.log(`Removed ${removed.length} PIG-hosted learn resource(s). Files on disk are untouched.`); }