Files
pig/packages/db/src/seed/demo/learn.ts
T
karti 8a6601dfce
CI / verify (push) Successful in 7m14s
CI / publish (push) Has been skipped
Re-cut the Motion films in landscape, and hold the camera still
THE FORMAT. The first pair were vertical. That was wrong for this product: PIG
is a desktop application, and reframing it 9:16 either shrinks it past reading
or crops away the sidebar and half the table. These are 1920x1080, like the five
already on the page, and the app is filmed at a real 1920 desktop viewport —
the previous renderer set the viewport to 960 and captured at 2x, which produced
a 1920px image of the COLLAPSED layout, because the viewport is what the product
responds to and the scale factor is only how much detail there is to zoom into.

THE CAMERA. It now holds still, and eases in on the thing the narration is
naming, holds, and eases back out — the way Cap zooms toward a click. The first
cut drifted a slow zoom across every shot, which reads as restless rather than
as emphasis and is the thing people mean when they call a screen recording
annoying. Six of fourteen shots move in the customer film; the rest are locked
off. The curve is a smoothstep either side of a hold, written as a function of
the frame number rather than accumulated through zoompan's own `zoom` variable,
which refers to the previous frame and drifts off its endpoints.

Zoom targets are chosen as the SMALLEST element already in frame, not the last
match in document order. That was wrong in a way only a long page shows: the
Halcyon engagement prints the same phrases twice — once in the score note and
again inside the artefact bodies below — so "kappa 0.54" resolved to a paragraph
two and a half thousand pixels below the fold that no amount of scrolling would
reveal, because an ancestor clips it. Preferring what is on screen keeps the
zoom inside the frame the shot chose; preferring the smallest match makes naming
a figure land on the figure rather than on the card around it.

THE SCRIPTS were rewritten from nothing through a judge panel — three
independent angles each, scored on hook, concreteness, ear, tension and ending,
then synthesised and adversarially checked against the running product.

The second film is the one that was asked for: one customer, end to end. Halcyon
Research, scored 52.8 and scope down on the day of first discovery because
nothing had been measured, through legal and scoping and a budget path, to 82.8
and build once the verifier, the baseline and the environment landed — and the
two dimensions that fell inside that same twenty-five point rise. It closes on
the week-two dev split reading under its trajectory, because the honest thing
about that record is that the gate might not be met and it says so before anyone
asks. It runs 68 seconds; a feature tour is 30 and a story is not.
2026-08-19 02:59:29 -07:00

339 lines
14 KiB
TypeScript

/**
* 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 — `<slug>.<hash>.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>.<hash>.<ext>`. */
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 `<slug>.<hash>.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 <slug>.<hash>.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<void> {
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.`);
}