Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/src/music-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export async function renderScore(
const decoded = spawnSync(
resolveFfmpeg(),
["-v", "error", "-i", stem, "-f", "f32le", "-ar", "48000", "-ac", "2", "pipe:1"],
{ maxBuffer: 256 * 1024 * 1024 },
// f32 stereo at 48 kHz, plus headroom: long scores exceed a fixed cap.
{ maxBuffer: Math.max(256 * 1024 * 1024, Math.ceil((compiled.durationSeconds + 2) * 48000 * 8)) },
);
if (decoded.status !== 0) throw new Error(`Cannot decode ${t.id} for room processing`);
const n = decoded.stdout.length / 8,
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions third-party/fonts/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@
{
"file": "videos/intern-promo/src/fonts/4iCr6K5wfMRRjxp0DA6-2CLnB4NHhqcL71Q.woff2",
"sha256": "4f4dc27f4a770c0d02fde800daa836c8adc0d1e423b28da74baaf0d1cc3ab96c"
},
{
"file": "videos/auc-ml/src/fonts/4iCr6K5wfMRRjxp0DA6-2CLnB41HhqcL71QxtQ.woff2",
"sha256": "e3085219252209a8d4128f90ff6d793315c752f73a979082d1d85d9cb46f63a4"
},
{
"file": "videos/auc-ml/src/fonts/4iCr6K5wfMRRjxp0DA6-2CLnB45HhqcL71QxtQ.woff2",
"sha256": "ed108107e74221cc5e163f12c6b35ba3e75e7615283828d6862c7a0784e1b0c9"
},
{
"file": "videos/auc-ml/src/fonts/4iCr6K5wfMRRjxp0DA6-2CLnB4NHhqcL71Q.woff2",
"sha256": "4f4dc27f4a770c0d02fde800daa836c8adc0d1e423b28da74baaf0d1cc3ab96c"
}
]
},
Expand All @@ -90,6 +102,22 @@
{
"file": "videos/intern-promo/src/fonts/jizBRFtNs2ka5fXjeivQ4LroWlx-6zUTjnTLgNs.woff2",
"sha256": "60c06664b5a95c7de6cc3e00d1f9034d78bd1e40b564016b241674449a067d4d"
},
{
"file": "videos/auc-ml/src/fonts/jizBRFtNs2ka5fXjeivQ4LroWlx-6zUTjnTLgNs.woff2",
"sha256": "60c06664b5a95c7de6cc3e00d1f9034d78bd1e40b564016b241674449a067d4d"
},
{
"file": "videos/auc-ml/src/fonts/jizBRFtNs2ka5fXjeivQ4LroWlx-6zsTjnTLgNuZ5w.woff2",
"sha256": "a8c4bd7cd7073180e740d2d83a616b5cb0845579b73207eeafeae8532e70c901"
},
{
"file": "videos/auc-ml/src/fonts/jizHRFtNs2ka5fXjeivQ4LroWlx-6zAjgn7Motmp5r61.woff2",
"sha256": "a04fc7ed18a8037149ce0bfda58076709d8e0840e136ed00abbdc196b7992443"
},
{
"file": "videos/auc-ml/src/fonts/jizHRFtNs2ka5fXjeivQ4LroWlx-6zAjjH7Motmp5g.woff2",
"sha256": "6ee678c33f388dd7ba59700ebea635deb98821baafd817b09891f7927177f702"
}
]
},
Expand All @@ -106,6 +134,14 @@
{
"file": "videos/intern-promo/src/fonts/Jqz55SSPQuCQF3t8uOwiUL-taUTtap9GayojdSFO.woff2",
"sha256": "e3b56e90510a84ac0ed465b822e112983eaf58e37436bf769681c31f77b1f3a7"
},
{
"file": "videos/auc-ml/src/fonts/Jqz55SSPQuCQF3t8uOwiUL-taUTtap9GayojdSFO.woff2",
"sha256": "e3b56e90510a84ac0ed465b822e112983eaf58e37436bf769681c31f77b1f3a7"
},
{
"file": "videos/auc-ml/src/fonts/Jqz55SSPQuCQF3t8uOwiUL-taUTtap9IayojdSFOd1I.woff2",
"sha256": "f5df746dbc2eccec6d22fdaaa2d253cd6915a30ce9f6ba86138bb6151c7254db"
}
]
},
Expand Down
56 changes: 56 additions & 0 deletions videos/auc-ml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# AUC in machine learning

An intuition-first narrated explanation, 12:23.5 at 1920×1080 / 30 fps. One synthetic
six-payment example connects thresholds, ROC geometry, pairwise ranking and average
precision, then returns for the closing comparison (7/9 vs 29/36). A short survey covers
partial and multiclass AUC and other curves that share the letters.

- Composition: `auc-ml`; entry: `src/index.tsx`. Title card after the cold-open hook; end card closes.
- Narration: Kokoro `af_heart`, speed 1, locked in `clapper-voices.lock.json`. One cue **per sentence**,
placed from measured takes, so each diagram step lands on the sentence that explains it
(`lineStarts()` in `src/plan.ts`).
- Score: `src/score.ts`, a quiet 60 BPM piano/viola/cello bed with harp glints at part changes.
It dips under speech by automation derived from the same timing.
- Type: Schibsted Grotesk / Fragment Mono / Instrument Serif (`src/fonts`, full Latin coverage).
- [Research and conventions](SOURCES.md) · [full transcript](transcript.md).
- `python3 videos/auc-ml/verify_math.py` checks every on-screen number with exact fractions.

## Retiming after a script edit

```fish
node prepare-timing.mjs probe # src/probe.json, one cue per sentence
pnpm exec clapper narration render src/probe.json -o out/probe-lines
node prepare-timing.mjs # src/timing.json, chapters, transcript
```

Unchanged sentences reuse cached takes. Scenes, narration and score all read `src/timing.json`.

- Preview: `pnpm --dir videos/auc-ml preview`.
- Render: `pnpm --dir videos/auc-ml render` (adds native MP4 chapter metadata → `out/auc-ml.mp4`).

## Chapters

| Start | Chapter |
| --- | --- |
| 00:00 | Who should be reviewed first? |
| 00:34 | Scores → thresholds → curve → area |
| 01:05 | A score gives an ordering |
| 01:36 | One threshold gives one decision |
| 02:06 | Two rates, two different denominators |
| 02:38 | Lower the threshold. Trace the tradeoff. |
| 03:05 | Width × height, added across the curve |
| 03:37 | Area is also a ranking game |
| 04:06 | Each strip is one column of comparisons |
| 04:36 | The probability behind ROC-AUC |
| 05:08 | Equal scores mean no ranking preference |
| 05:40 | 1 is perfect. 0.5 is the chance reference. |
| 06:10 | Same ordering. Same AUC. Different numbers. |
| 06:40 | A small false-positive rate can mean many alarms |
| 07:21 | Precision asks about the flagged pile |
| 07:58 | Reward precision when a new positive is found |
| 08:30 | PR-AUC and AP are not interchangeable labels |
| 09:09 | Partial AUC: only the region you operate in |
| 09:40 | Multiclass AUC needs a decomposition and an average |
| 10:26 | Same letters, different curves |
| 11:14 | Keep the curve, the data, and the decision separate |
| 11:43 | Ask five questions when someone says “AUC” |
37 changes: 37 additions & 0 deletions videos/auc-ml/SOURCES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# AUC in machine learning — source and scope

A narrated, intuition-first explanation for a software engineer comfortable with
basic algebra. Core ROC and AP derivations use one original six-payment example.
Other ML contexts are explicitly new examples or a map of variants, not claims
that every use of “AUC” shares one definition. No pharmacokinetic content.

Primary/official references checked 2026-09-15:

1. [Fawcett, An introduction to ROC analysis (2006)](https://www.math.ucdavis.edu/~saito/data/roc/fawcett-roc.pdf): threshold sweeps, ROC geometry, ranking interpretation, chance reference and deployment cautions.
2. [scikit-learn ROC-AUC API](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.roc_auc_score.html): score inputs, OvR/OvO, macro/weighted aggregation and standardized partial AUC.
3. [scikit-learn average precision API](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.average_precision_score.html): recall-weighted non-interpolated AP and distinction from linear trapezoidal PR area.
4. [scikit-learn metric guide](https://scikit-learn.org/stable/modules/model_evaluation.html): ROC/PR definitions, averaging and score-based evaluation.
5. [Davis & Goadrich, The Relationship Between Precision-Recall and ROC Curves (2006)](https://research.cs.wisc.edu/techreports/2006/TR1551.pdf): relationships and interpolation caveats. The original worked numbers below are independently calculated.
6. [COCO official evaluator](https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocotools/cocoeval.py): detection matching, interpolated precision, 101 recall thresholds and IoU thresholds .50:.05:.95.
7. [scikit-survival cumulative/dynamic AUC](https://scikit-survival.readthedocs.io/en/stable/api/generated/sksurv.metrics.cumulative_dynamic_auc.html): event-by-horizon cases, event-free controls, censoring-aware weights and time aggregation. Use the standard FPR/TPR ROC axes from ref1; the API page's background sentence about specificity is not our axis definition.
8. [scikit-learn ROC cross-validation example](https://scikit-learn.org/stable/auto_examples/model_selection/plot_roc_crossval.html): out-of-sample evaluation and variation across splits.

9. [TensorFlow AUC metric](https://www.tensorflow.org/api_docs/python/tf/keras/metrics/AUC): fixed-threshold discretization can approximate ROC/PR area; exact rank invariance does not promise invariant coarse-grid estimates.

## Original numerical examples

- Sorted scores: A+ .9, B− .8, C+ .7, D+ .6, E− .4, F− .1.
- At cutoff .65: TP2, FP1, FN1, TN2; TPR2/3, FPR1/3, precision2/3.
- ROC points: (0,0),(0,1/3),(1/3,1/3),(1/3,2/3),(1/3,1),(2/3,1),(1,1).
- ROC area: 7/9; positive-negative wins: 7/9, no ties.
- Non-interpolated AP: (1 + 2/3 + 3/4)/3 = 29/36.
- Linear trapezoidal area of raw PR points, with endpoint (recall0,precision1): 55/72.
- Partial raw ROC area over FPR[0,.1]: 1/30; maximum raw area .1. McClish-standardized (scikit-learn `max_fpr=0.1`): ½(1+(1/30−.005)/(.1−.005)) ≈ .649, hand-computed.
- Prevalence example: fixed TPR.8/FPR.1 gives precision80/90 for100P/100N, but8/107 for10P/990N. Invariance is conditional on keeping class-conditional scoring behavior fixed.
- Interpolated precision envelope (max precision at recall ≥ r): 1 to recall 1/3, then 3/4.
- Retrieval callback: relevant results at ranks 1, 3, 4 give the same AP, 29/36.
- Separate multiclass illustration (labelled as three fraud types): per-class areas .95,.80,.60, supports80,15,5. Macro47/60≈.78333; support-weighted.91.
- Detection illustration: equal100×100 boxes offset20 horizontally have IoU2/3. Matching passes a.5 IoU cutoff and fails.75.

`verify_math.py` checks these independently with exact rational arithmetic.
The six-item sample teaches mechanics; it cannot establish real model quality.
16 changes: 16 additions & 0 deletions videos/auc-ml/clapper-voices.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"schema": 1,
"engine": {
"model": "kokoro-82m-v1-q8",
"revision": "1939ad2a8e416c0acfeecc08a694d14ef25f2231",
"runtime": "478f7432db253cbd8e530daca9641885f62e7c529a6a9e1ab528564aee39bc0b",
"renderer": 1
},
"narrators": {
"guide": {
"voice": "af_heart",
"speed": 1,
"embedding": "d583ccff3cdca2f7fae535cb998ac07e9fcb90f09737b9a41fa2734ec44a8f0b"
}
}
}
7 changes: 7 additions & 0 deletions videos/auc-ml/clapper.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"runtime": "0.4.0",
"entry": "src/index.tsx",
"composition": "auc-ml",
"narration": "src/narration.ts",
"score": "src/score.ts"
}
47 changes: 47 additions & 0 deletions videos/auc-ml/finalize.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveFfmpeg } from "@archastro/clapper";

const root = path.dirname(fileURLToPath(import.meta.url));
const chapters = JSON.parse(fs.readFileSync(path.join(root, "out/chapters.json")));
const escape = (s) => s.replace(/[\\=;#\n]/g, (c) => "\\" + c);
const lines = [";FFMETADATA1", "title=AUC in machine learning — intuition and mathematical rigor"];
for (const c of chapters)
lines.push(
"[CHAPTER]",
"TIMEBASE=1/1000",
`START=${Math.round(c.startSeconds * 1000)}`,
`END=${Math.round((c.startSeconds + c.durationSeconds) * 1000)}`,
`title=${escape(c.title)}`,
);
const metadata = path.join(root, "out/chapters.ffmeta");
fs.writeFileSync(metadata, lines.join("\n") + "\n");
const r = spawnSync(
resolveFfmpeg(),
[
"-hide_banner",
"-loglevel",
"error",
"-i",
path.join(root, "out/render.mp4"),
"-i",
metadata,
"-map",
"0",
"-map_metadata",
"1",
"-map_chapters",
"1",
"-c",
"copy",
"-movflags",
"+faststart",
"-y",
path.join(root, "out/auc-ml.mp4"),
],
{ stdio: "inherit" },
);
if (r.status !== 0) throw Error("Chapter mux failed");
console.log("Chaptered MP4: out/auc-ml.mp4");
22 changes: 22 additions & 0 deletions videos/auc-ml/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "auc-ml",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"preview": "clapper preview",
"render": "clapper render -c auc-ml --crf 20 -o out/render.mp4 && node finalize.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@archastro/clapper-core": "workspace:*",
"@archastro/clapper": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@archastro/clapper-music": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}
101 changes: 101 additions & 0 deletions videos/auc-ml/prepare-timing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Narration timing from natural paragraph takes.
// node prepare-timing.mjs probe → src/probe.json (one cue per scene, generous windows)
// clapper narration render src/probe.json -o out/probe-para
// node prepare-timing.mjs → src/timing.json, out/chapters.json, transcript.md
// Each scene is one take (Kokoro's own sentence rhythm). Sentence onsets inside the take
// come from its pauses (sentence-bounds.mjs), so scenes can animate on the sentence that
// explains each step (lineStarts in src/plan.ts).
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { sentenceOnsets } from "./sentence-bounds.mjs";

const root = path.dirname(fileURLToPath(import.meta.url));
const beats = JSON.parse(fs.readFileSync(path.join(root, "src/beats.json")));
export const sentences = (text) => text.split(/(?<=[.?!])\s+(?=[A-Z“"(])/).filter(Boolean);

const LEAD = 0.6; // scene start → take start (the take carries ~0.3 s of its own lead-in)
const HOLD = { default: 1.8, math: 3.0 }; // take end → cut
const math = new Set([
"area",
"pairs",
"bridge",
"rigor",
"ties",
"imbalance",
"ap",
"conventions",
"partial",
"multiclass",
]);

if (process.argv[2] === "probe") {
let at = 0;
const cues = [];
for (const b of beats) {
if (!b.text) continue;
const window = Math.max(40, Math.ceil(b.text.split(/\s+/).length / 1.5) + 10);
cues.push({ id: b.id, narrator: "guide", text: b.text, at, duration: window });
at += window;
}
const probe = {
title: "AUC in machine learning — paragraph probe",
narrators: { guide: { voice: "af_heart", speed: 1 } },
cues,
};
fs.writeFileSync(path.join(root, "src/probe.json"), JSON.stringify(probe, null, 2) + "\n");
console.log(`${cues.length} paragraph cues → src/probe.json`);
process.exit(0);
}

const measured = JSON.parse(fs.readFileSync(path.join(root, "out/probe-para/manifest.json")));
const takes = new Map();
const takeDir = path.join(root, ".clapper/narration/takes");
for (const f of fs.readdirSync(takeDir).filter((f) => f.endsWith(".json"))) {
const t = JSON.parse(fs.readFileSync(path.join(takeDir, f)));
takes.set(t.sha256, t);
}
const timing = {};
const chapters = [];
let at = 0;
for (const b of beats) {
let seconds = b.seconds,
take,
lines = [];
if (b.text) {
const c = measured.cues.find((c) => c.id === b.id);
assert.ok(c, `No measured take for ${b.id}`);
assert.equal(c.text, b.text, `Stale measured text for ${b.id}`);
const file = takes.get(c.sha256)?.file;
assert.ok(file, `Take audio for ${b.id} not in the narration cache`);
const parts = sentences(b.text);
const r = sentenceOnsets(file, parts.length);
lines = r.onsets.map((o, k) => {
const end = k + 1 < r.onsets.length ? r.onsets[k + 1] : r.voiceEnd;
// Plausibility: speech runs ~5–12 cs per character; a misplaced boundary breaks that.
const rate = ((end - o) / parts[k].length) * 100;
assert.ok(
rate > 4 && rate < 13,
`${b.id} sentence ${k}: implausible boundary (${rate.toFixed(1)} cs/char)`,
);
return { at: Math.round((LEAD + o) * 100) / 100, dur: Math.round((end - o) * 100) / 100 };
});
take = { at: LEAD, dur: c.durationSeconds };
seconds = Math.ceil((LEAD + c.durationSeconds + (math.has(b.id) ? HOLD.math : HOLD.default)) * 2) / 2;
}
timing[b.id] = { seconds, take, lines };
if (b.title) chapters.push({ id: b.id, title: b.title, startSeconds: at, durationSeconds: seconds });
at += seconds;
}
fs.writeFileSync(path.join(root, "src/timing.json"), JSON.stringify(timing, null, 2) + "\n");
fs.mkdirSync(path.join(root, "out"), { recursive: true });
fs.writeFileSync(path.join(root, "out/chapters.json"), JSON.stringify(chapters, null, 2) + "\n");
const stamp = (s) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(1).padStart(4, "0")}`;
fs.writeFileSync(
path.join(root, "transcript.md"),
chapters
.map((c) => `## ${stamp(c.startSeconds)} — ${c.title}\n\n${beats.find((x) => x.id === c.id).text}\n`)
.join("\n"),
);
console.log(`Film: ${at}s (${(at / 60).toFixed(2)} min), ${chapters.length} chapters.`);
Loading
Loading