explainer-video 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/cordis.patch.yml +7 -0
- package/lib/index.js +35 -0
- package/package.json +56 -4
- package/plugin.json +13 -0
- package/skills/coursework-author/.synced-from +5 -0
- package/skills/coursework-author/SKILL.md +209 -0
- package/skills/craft-codeflow-flows/SKILL.md +116 -0
- package/skills/craft-codeflow-mine/SKILL.md +207 -0
- package/skills/craft-codeflow-plan/SKILL.md +65 -0
- package/skills/craft-codeflow-scene/SKILL.md +168 -0
- package/skills/craft-video-align/SKILL.md +109 -0
- package/skills/craft-video-assemble/SKILL.md +69 -0
- package/skills/craft-video-audio/SKILL.md +59 -0
- package/skills/craft-video-enrich/SKILL.md +46 -0
- package/skills/craft-video-plan/SKILL.md +57 -0
- package/skills/craft-video-publish/SKILL.md +79 -0
- package/skills/craft-video-render/SKILL.md +68 -0
- package/skills/craft-video-scene/SKILL.md +112 -0
- package/skills/craft-video-script/SKILL.md +80 -0
- package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
- package/skills/explainer-video-from-codebase/SKILL.md +659 -0
- package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
- package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
- package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
- package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
- package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
- package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
- package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
- package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
- package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
- package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
- package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
- package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
- package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
- package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
- package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
- package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
- package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
- package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
- package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
- package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
- package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
- package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
- package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
- package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
- package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
- package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
- package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
- package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
- package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
- package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
- package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
- package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
- package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
- package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
- package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
- package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
- package/skills/explainer-video-from-coursework/SERIES.md +232 -0
- package/skills/explainer-video-from-coursework/SETUP.md +105 -0
- package/skills/explainer-video-from-coursework/SKILL.md +858 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
- package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
- package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
- package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
- package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
- package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
- package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
- package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
- package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
- package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
- package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
- package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
- package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
- package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
- package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
- package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
- package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
- package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
- package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
- package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
- package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
- package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
- package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
- package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
- package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
- package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
- package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
- package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
- package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
- package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
planner.mjs — turn a directory of source notes into a VIDEO SERIES PLAN.
|
|
4
|
+
|
|
5
|
+
This is the stage that decides HOW MANY videos a body of content needs, in
|
|
6
|
+
what form, in what order — and proves nothing was silently dropped.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
node tools/planner.mjs <sourceDir> --out out/<name> [--max-min 25] [--title "..."]
|
|
10
|
+
|
|
11
|
+
Pipeline position:
|
|
12
|
+
source notes -> [PLANNER] -> plan.json -> script -> scene -> audio -> video
|
|
13
|
+
|
|
14
|
+
Design rules:
|
|
15
|
+
• Every number is derived and printed, so the plan can be argued with.
|
|
16
|
+
• Coverage is ASSERTED, not assumed: every source section lands on a video
|
|
17
|
+
or is excluded with a stated reason. Orphans are reported as failures.
|
|
18
|
+
• Form is inferred from evidence (code ratio, link ratio, diagram markers)
|
|
19
|
+
and the evidence is emitted alongside the verdict.
|
|
20
|
+
========================================================================== */
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join, relative, extname, basename, sep, dirname, resolve } from 'node:path';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { WPM, EXPANSION } from './model.mjs';
|
|
26
|
+
|
|
27
|
+
/* ---------------- tunable model ---------------- */
|
|
28
|
+
const DEFAULTS = {
|
|
29
|
+
wpm: WPM, // narration words per minute — measured, see model.mjs
|
|
30
|
+
expansion: EXPANSION, // visual/beat overhead on top of pure narration time — measured
|
|
31
|
+
maxMin: 25, // per-video hard cap (aligned to the 30-min transcribe ceiling)
|
|
32
|
+
minMin: 2.5, // below this, try to merge with a sibling
|
|
33
|
+
codeNarration: 0.15, // fraction of code words that get narrated (you show, then explain briefly)
|
|
34
|
+
tableNarration: 0.40, // tables get condensed, not read out
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/* ---------------- args ---------------- */
|
|
38
|
+
const argv = process.argv.slice(2);
|
|
39
|
+
// The source is the first bare argument — but a flag's *value* is also bare, so
|
|
40
|
+
// skip anything that followed a flag which takes one.
|
|
41
|
+
const VALUED = new Set(['--out', '--max-min', '--min-min', '--wpm', '--expansion', '--title', '--exclude']);
|
|
42
|
+
const sourceDir = argv.find((a, i) => !a.startsWith('--') && !(i > 0 && VALUED.has(argv[i - 1])));
|
|
43
|
+
const flag = (n, d) => {
|
|
44
|
+
const i = argv.indexOf('--' + n);
|
|
45
|
+
return i === -1 ? d : argv[i + 1];
|
|
46
|
+
};
|
|
47
|
+
// Repeatable: --exclude a --exclude b (a published destination usually needs one).
|
|
48
|
+
const flags = (n) => argv.flatMap((a, i) => (a === '--' + n && argv[i + 1] ? [argv[i + 1]] : []));
|
|
49
|
+
if (!sourceDir) {
|
|
50
|
+
console.error('usage: node tools/planner.mjs <sourceDir> --out <outDir> [options]');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const CFG = {
|
|
54
|
+
...DEFAULTS,
|
|
55
|
+
wpm: parseFloat(flag('wpm', DEFAULTS.wpm)),
|
|
56
|
+
expansion: parseFloat(flag('expansion', DEFAULTS.expansion)),
|
|
57
|
+
maxMin: parseFloat(flag('max-min', DEFAULTS.maxMin)),
|
|
58
|
+
force: !!flag('force', false), // overwrite a plan whose model differs (see the guard below)
|
|
59
|
+
minMin: parseFloat(flag('min-min', DEFAULTS.minMin)),
|
|
60
|
+
outDir: flag('out', 'out/plan'),
|
|
61
|
+
title: flag('title', basename(sourceDir.replace(/\/+$/, ''))),
|
|
62
|
+
};
|
|
63
|
+
const CAP_SEC = CFG.maxMin * 60;
|
|
64
|
+
const MIN_SEC = CFG.minMin * 60;
|
|
65
|
+
// Pack to slightly under the cap so a heading's own intro prose can still be
|
|
66
|
+
// attached to the video it belongs to without breaching the real limit.
|
|
67
|
+
const PACK_CAP = CAP_SEC * 0.97;
|
|
68
|
+
|
|
69
|
+
/* =============================================================================
|
|
70
|
+
1. Walk the source tree
|
|
71
|
+
========================================================================== */
|
|
72
|
+
const EXCLUDE = flags('exclude').map(d => resolve(d));
|
|
73
|
+
function walk(dir, base = dir) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const name of readdirSync(dir)) {
|
|
76
|
+
if (name.startsWith('.')) continue;
|
|
77
|
+
const p = join(dir, name);
|
|
78
|
+
if (EXCLUDE.some(x => resolve(p) === x || resolve(p).startsWith(x + sep))) continue;
|
|
79
|
+
const st = statSync(p);
|
|
80
|
+
if (st.isDirectory()) out.push(...walk(p, base));
|
|
81
|
+
else out.push({ path: p, rel: relative(base, p), ext: extname(p).toLowerCase(), bytes: st.size });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
// Fail cleanly on a bad path rather than emitting a raw ENOENT stack trace.
|
|
86
|
+
if (!existsSync(sourceDir)) {
|
|
87
|
+
console.error(`[planner] source directory not found: ${sourceDir}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (!statSync(sourceDir).isDirectory()) {
|
|
91
|
+
console.error(`[planner] not a directory: ${sourceDir}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const files = walk(sourceDir);
|
|
95
|
+
const mdFiles = files.filter(f => f.ext === '.md');
|
|
96
|
+
|
|
97
|
+
/* A destination placed inside the source gets re-ingested on the next plan: the
|
|
98
|
+
sidecars we published (`description.md`, `captions.srt`) read as course notes and
|
|
99
|
+
turn into phantom videos. Detect the tell-tale name and say how to fix it. */
|
|
100
|
+
const sidecars = mdFiles.filter(f => basename(f.path) === 'description.md');
|
|
101
|
+
if (sidecars.length) {
|
|
102
|
+
const dirs = [...new Set(sidecars.map(f => dirname(f.path)))];
|
|
103
|
+
console.error(`\n[planner] ${sidecars.length} of the ${mdFiles.length} input files are `
|
|
104
|
+
+ '`description.md` — that is the name this pipeline publishes. They are outputs, not notes.');
|
|
105
|
+
for (const d of dirs.slice(0, 4)) console.error(` ${relative(sourceDir, d) || '.'}/description.md`);
|
|
106
|
+
console.error(' Pass --exclude <that directory> (repeatable) — or --exclude your');
|
|
107
|
+
console.error(' destination — so published sidecars never re-enter the plan.\n');
|
|
108
|
+
}
|
|
109
|
+
const codeFiles = files.filter(f => ['.java', '.py', '.ts', '.js', '.go', '.cpp', '.c', '.rs'].includes(f.ext));
|
|
110
|
+
|
|
111
|
+
// Refuse to plan nothing. An empty or all-code directory used to produce a
|
|
112
|
+
// silent "1 video, 0 min" plan, which reads as success and is useless.
|
|
113
|
+
if (!mdFiles.length) {
|
|
114
|
+
console.error(`[planner] no markdown files under ${sourceDir}`);
|
|
115
|
+
console.error(` found ${files.length} file(s), none of them .md — nothing to plan.`);
|
|
116
|
+
console.error(' (only .md is parsed; .mdx/.rst/.txt/.ipynb are not supported yet)');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* =============================================================================
|
|
121
|
+
2. Parse markdown into a section list, with code fences respected
|
|
122
|
+
========================================================================== */
|
|
123
|
+
const LINK_RE = /(https?:\/\/|\[[^\]]*\]\([^)]*\))/;
|
|
124
|
+
// A fence may sit behind a list marker ("5. ```java"). Missing that desynchronises
|
|
125
|
+
// the fence state and silently swallows every later heading in the file.
|
|
126
|
+
const FENCE_RE = /^(?:[-*+]\s+|\d+[.)]\s+)?```/;
|
|
127
|
+
const DIAGRAM_LANG = /^(mermaid|plantuml|graphviz|dot|d2)\b/i;
|
|
128
|
+
|
|
129
|
+
function parseMarkdown(rel, text) {
|
|
130
|
+
const lines = text.split('\n');
|
|
131
|
+
const sections = []; // {level, title, line, endLine, prose, code, diagram, table, link}
|
|
132
|
+
let fence = false;
|
|
133
|
+
let fenceLang = '';
|
|
134
|
+
let cur = { level: 0, title: '(preamble)', line: 0, prose: 0, code: 0, diagram: 0, table: 0, link: 0, codeBlocks: 0 };
|
|
135
|
+
const flush = (endLine) => { cur.endLine = endLine; sections.push(cur); };
|
|
136
|
+
|
|
137
|
+
lines.forEach((raw, i) => {
|
|
138
|
+
const line = raw.trimEnd();
|
|
139
|
+
|
|
140
|
+
if (FENCE_RE.test(line.trim())) {
|
|
141
|
+
if (!fence) {
|
|
142
|
+
fence = true; cur.codeBlocks++;
|
|
143
|
+
fenceLang = line.trim().replace(FENCE_RE, '').trim().split(/\s+/)[0] || '';
|
|
144
|
+
} else fence = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (fence) {
|
|
148
|
+
const w = line.split(/\s+/).filter(Boolean).length;
|
|
149
|
+
// A diagram fence is a visual to render, not code to narrate — it must
|
|
150
|
+
// not inflate codeRatio (that was misclassifying mermaid as `code`).
|
|
151
|
+
if (DIAGRAM_LANG.test(fenceLang)) cur.diagram += w; else cur.code += w;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
156
|
+
if (h) {
|
|
157
|
+
flush(i);
|
|
158
|
+
cur = {
|
|
159
|
+
level: h[1].length,
|
|
160
|
+
title: h[2].replace(/<a\s+name="[^"]*">\s*<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(),
|
|
161
|
+
line: i, prose: 0, code: 0, diagram: 0, table: 0, link: 0, codeBlocks: 0,
|
|
162
|
+
};
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const words = line.split(/\s+/).filter(Boolean).length;
|
|
167
|
+
if (!words) return;
|
|
168
|
+
if (LINK_RE.test(line) && words < 14) cur.link += words; // link/list lines aren't narrated verbatim
|
|
169
|
+
else if (/^\s*\|/.test(line)) cur.table += words; // table row
|
|
170
|
+
else cur.prose += words;
|
|
171
|
+
});
|
|
172
|
+
flush(lines.length);
|
|
173
|
+
|
|
174
|
+
// narratable words for a section (cost model)
|
|
175
|
+
const cost = s => s.prose + CFG.codeNarration * s.code + CFG.tableNarration * s.table;
|
|
176
|
+
sections.forEach(s => { s.src = rel; s.narratable = Math.round(cost(s)); });
|
|
177
|
+
return sections.filter(s => s.title !== '(preamble)' || s.prose > 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* =============================================================================
|
|
181
|
+
3. Build a single section list across all markdown, then a tree
|
|
182
|
+
========================================================================== */
|
|
183
|
+
let allSections = [];
|
|
184
|
+
const parseDiag = [];
|
|
185
|
+
for (const f of mdFiles) {
|
|
186
|
+
const text = readFileSync(f.path, 'utf8');
|
|
187
|
+
const secs = parseMarkdown(f.rel, text);
|
|
188
|
+
secs.forEach(s => { s.file = f.rel; });
|
|
189
|
+
allSections = allSections.concat(secs);
|
|
190
|
+
// Self-check: coverage can only assert over what the parser SAW. If the parser
|
|
191
|
+
// silently swallowed headings, coverage passes vacuously. Compare against the
|
|
192
|
+
// raw count of heading-like lines (the delta is usually code comments in fences).
|
|
193
|
+
const rawHeadings = text.split('\n').filter(l => /^#{1,6}\s/.test(l.trim())).length;
|
|
194
|
+
parseDiag.push({ file: f.rel, rawHeadingLines: rawHeadings, parsedHeadings: secs.filter(s => s.title !== '(preamble)').length });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// tree from heading levels. Each FILE gets its own node under the root, so
|
|
198
|
+
// packing can never merge unrelated files into one video.
|
|
199
|
+
const root = { id: 'root', title: CFG.title, level: -1, children: [], own: 0, src: '', isRoot: true };
|
|
200
|
+
function buildTree(secs, parent) {
|
|
201
|
+
const stack = [parent];
|
|
202
|
+
for (const s of secs) {
|
|
203
|
+
while (stack.length && stack[stack.length - 1].level >= s.level) stack.pop();
|
|
204
|
+
const parentNode = stack.length ? stack[stack.length - 1] : parent;
|
|
205
|
+
const n = {
|
|
206
|
+
id: `${s.file}:${s.line}`, title: s.title, level: s.level, src: s.file,
|
|
207
|
+
own: s.narratable, prose: s.prose, code: s.code, diagram: s.diagram, table: s.table, link: s.link,
|
|
208
|
+
children: [], section: s,
|
|
209
|
+
};
|
|
210
|
+
parentNode.children.push(n);
|
|
211
|
+
stack.push(n);
|
|
212
|
+
}
|
|
213
|
+
return parent;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const byFile = {};
|
|
217
|
+
allSections.forEach(s => { (byFile[s.file] = byFile[s.file] || []).push(s); });
|
|
218
|
+
for (const f of Object.keys(byFile)) {
|
|
219
|
+
const h1s = byFile[f].filter(s => s.level === 1);
|
|
220
|
+
// A file whose first H1 is just the first of MANY H1s (OOP/README.md) has no
|
|
221
|
+
// real document title. Prefer the containing directory name — "OOP", not "README".
|
|
222
|
+
const dir = f.includes(sep) ? f.split(sep).slice(-2, -1)[0] : null;
|
|
223
|
+
const title = h1s.length === 1 ? h1s[0].title : (dir || basename(f, '.md'));
|
|
224
|
+
const fileNode = {
|
|
225
|
+
id: 'file:' + f, title, level: 0, children: [], own: 0, src: f, isFile: true,
|
|
226
|
+
};
|
|
227
|
+
root.children.push(fileNode);
|
|
228
|
+
buildTree(byFile[f], fileNode);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* path from root down to a node, for lowest-common-ancestor lookups */
|
|
232
|
+
function pathTo(target, node = root, chain = []) {
|
|
233
|
+
const c = chain.concat(node);
|
|
234
|
+
if (node === target) return c;
|
|
235
|
+
for (const k of node.children) {
|
|
236
|
+
const r = pathTo(target, k, c);
|
|
237
|
+
if (r) return r;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
/** deepest node that is an ancestor-or-self of every unit */
|
|
242
|
+
function commonAncestor(units) {
|
|
243
|
+
if (!units.length) return root;
|
|
244
|
+
const paths = units.map(u => pathTo(u)).filter(Boolean);
|
|
245
|
+
if (!paths.length) return root;
|
|
246
|
+
let lca = root;
|
|
247
|
+
const min = Math.min(...paths.map(p => p.length));
|
|
248
|
+
for (let i = 0; i < min; i++) {
|
|
249
|
+
const cand = paths[0][i];
|
|
250
|
+
if (paths.every(p => p[i] === cand)) lca = cand; else break;
|
|
251
|
+
}
|
|
252
|
+
return lca;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const durOf = node => node.own + node.children.reduce((a, c) => a + durOf(c), 0);
|
|
256
|
+
const secsOf = node => (node.own > 0 || node.diagram > 0 || !node.children.length ? [node] : []).concat(node.children.flatMap(secsOf));
|
|
257
|
+
const toSec = words => (words / CFG.wpm) * 60 * CFG.expansion;
|
|
258
|
+
|
|
259
|
+
/* =============================================================================
|
|
260
|
+
4. Exclude navigation sections — and nothing else
|
|
261
|
+
|
|
262
|
+
There used to be an "index pointer" inference here: if a section's title
|
|
263
|
+
contained a directory name, and that directory held 2x the words, the section
|
|
264
|
+
was excluded as a mere summary of that source. It was DELETED because:
|
|
265
|
+
|
|
266
|
+
• it silently dropped real content on unfamiliar trees — a section titled
|
|
267
|
+
"☕ Java" matched an unrelated JAVA/ directory elsewhere in the tree;
|
|
268
|
+
• gating it behind a flag only moved the problem onto the user, who cannot
|
|
269
|
+
know which directories are "index targets" in a folder they just pointed us at;
|
|
270
|
+
• measured on the reference tree it changed neither the video count, nor the
|
|
271
|
+
orphan count, nor the longest video — only the total, by 3 minutes in 59.
|
|
272
|
+
|
|
273
|
+
So there is no name-based inference. Navigation headings are the one
|
|
274
|
+
unambiguous case, and that is all we remove. Everything else is planned.
|
|
275
|
+
========================================================================== */
|
|
276
|
+
const overlaps = [];
|
|
277
|
+
(function detectNav(node) {
|
|
278
|
+
for (const ch of node.children) {
|
|
279
|
+
if (/(table of contents|^contents$|^toc$|^index$)/i.test(ch.title)) {
|
|
280
|
+
ch.excluded = true;
|
|
281
|
+
ch.indexOf = 'TOC';
|
|
282
|
+
overlaps.push({ section: ch.title, src: ch.src, child: '(navigation — not content)', ownWords: durOf(ch), childWords: 0 });
|
|
283
|
+
}
|
|
284
|
+
detectNav(ch);
|
|
285
|
+
}
|
|
286
|
+
})(root);
|
|
287
|
+
|
|
288
|
+
/* =============================================================================
|
|
289
|
+
5. Pack the tree into videos under the cap
|
|
290
|
+
========================================================================== */
|
|
291
|
+
const live = node => !node.excluded;
|
|
292
|
+
function packNode(node) {
|
|
293
|
+
const total = durOf(node);
|
|
294
|
+
// seconds vs seconds — comparing words here silently changes the effective cap
|
|
295
|
+
if (toSec(total) <= CAP_SEC && node !== root) return [{ units: [node], words: total }];
|
|
296
|
+
|
|
297
|
+
const out = [];
|
|
298
|
+
let cur = null;
|
|
299
|
+
let carried = 0;
|
|
300
|
+
for (const ch of node.children.filter(live)) {
|
|
301
|
+
for (const v of packNode(ch)) {
|
|
302
|
+
// compare in SECONDS, not words. Also: never glue two different source
|
|
303
|
+
// files into one video — OOP foundations do not belong with the intro.
|
|
304
|
+
const sameFile = cur && cur.units.length && v.units.length && cur.units[0].src === v.units[0].src;
|
|
305
|
+
if (sameFile && toSec(cur.words + v.words) <= PACK_CAP) {
|
|
306
|
+
cur.units.push(...v.units);
|
|
307
|
+
if (v.ownOnly) (cur.ownOnly = cur.ownOnly || []).push(...v.ownOnly); // don't drop container prose
|
|
308
|
+
cur.words += v.words;
|
|
309
|
+
} else {
|
|
310
|
+
cur = { units: [...v.units], ownOnly: v.ownOnly ? [...v.ownOnly] : [], words: v.words };
|
|
311
|
+
out.push(cur);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// A heading's own prose has to live somewhere. Attach the NODE but keep it in
|
|
316
|
+
// `ownOnly` — expanding it via secsOf() would falsely claim its whole subtree,
|
|
317
|
+
// which belongs to other videos.
|
|
318
|
+
if (node.own > 0 && !node.isRoot) {
|
|
319
|
+
if (out.length) { (out[0].ownOnly = out[0].ownOnly || []).push(node); out[0].words += node.own; }
|
|
320
|
+
else out.push({ units: [], ownOnly: [node], words: node.own });
|
|
321
|
+
}
|
|
322
|
+
return out.length ? out : [{ units: [node], words: total }];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let videos = packNode(root);
|
|
326
|
+
|
|
327
|
+
// merge runs that are individually too small, when they share a parent and fit
|
|
328
|
+
for (let i = 0; i < videos.length - 1; i++) {
|
|
329
|
+
const a = videos[i], b = videos[i + 1];
|
|
330
|
+
const pa = a.units[0], pb = b.units[0];
|
|
331
|
+
const sameSrc = pa.src === pb.src;
|
|
332
|
+
if (toSec(a.words) < MIN_SEC && sameSrc && toSec(a.words + b.words) <= CAP_SEC) {
|
|
333
|
+
// preserve ownOnly: container prose attached to either half must not vanish
|
|
334
|
+
videos[i] = {
|
|
335
|
+
units: a.units.concat(b.units),
|
|
336
|
+
ownOnly: (a.ownOnly || []).concat(b.ownOnly || []),
|
|
337
|
+
words: a.words + b.words,
|
|
338
|
+
};
|
|
339
|
+
videos.splice(i + 1, 1);
|
|
340
|
+
i--;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* =============================================================================
|
|
345
|
+
6. Classify form from evidence
|
|
346
|
+
========================================================================== */
|
|
347
|
+
function classify(v) {
|
|
348
|
+
const agg = { prose: 0, code: 0, diagram: 0, table: 0, link: 0, text: '' };
|
|
349
|
+
v.units.forEach(u => secsOf(u).forEach(s => {
|
|
350
|
+
agg.prose += s.prose || 0; agg.code += s.code || 0; agg.diagram += s.diagram || 0;
|
|
351
|
+
agg.table += s.table || 0; agg.link += s.link || 0;
|
|
352
|
+
agg.text += ' ' + (s.title || '');
|
|
353
|
+
}));
|
|
354
|
+
const total = agg.prose + agg.code + agg.diagram + agg.table + agg.link || 1;
|
|
355
|
+
const codeRatio = agg.code / total;
|
|
356
|
+
const diagramRatio = agg.diagram / total;
|
|
357
|
+
const linkRatio = agg.link / total;
|
|
358
|
+
const paths = v.units.map(u => u.src).join(' ').toLowerCase();
|
|
359
|
+
const ev = { codeRatio: +codeRatio.toFixed(3), diagramRatio: +diagramRatio.toFixed(3), linkRatio: +linkRatio.toFixed(3) };
|
|
360
|
+
|
|
361
|
+
// the code artefacts that sit alongside this video's sources
|
|
362
|
+
const prefixes = new Set(v.units.map(u => u.src.split(sep).slice(0, -1).join(sep)));
|
|
363
|
+
const artefacts = codeFiles
|
|
364
|
+
.filter(f => Array.from(prefixes).some(p => p && f.rel.startsWith(p + sep)) || (prefixes.has('') && !f.rel.includes(sep)))
|
|
365
|
+
.map(f => f.rel);
|
|
366
|
+
|
|
367
|
+
if (linkRatio > 0.5) return { form: 'resources', why: `linkRatio ${linkRatio.toFixed(2)} — a link list, not narratable`, artefacts, ev };
|
|
368
|
+
if (/violation|rectified|before|after/.test(paths)) return { form: 'refactor', why: 'source pair reads as before/after', artefacts, ev };
|
|
369
|
+
if (diagramRatio > 0.3 || /diagram|uml|notation|mermaid|sequence|state machine/.test(agg.text.toLowerCase()))
|
|
370
|
+
return { form: 'diagram', why: `diagramRatio ${diagramRatio.toFixed(2)} — mermaid/plantuml fences or diagram markers`, artefacts, ev };
|
|
371
|
+
if (/problem|taxonomy|catalog|directory|questions/.test(agg.text.toLowerCase()) && agg.table + agg.link > total * 0.3)
|
|
372
|
+
return { form: 'catalogue', why: 'enumerated problem/table listing', artefacts, ev };
|
|
373
|
+
if (codeRatio > 0.35) return { form: 'code', why: `codeRatio ${codeRatio.toFixed(2)}`, artefacts, ev };
|
|
374
|
+
return { form: 'concept', why: 'prose-dominant explanatory content', artefacts, ev };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const cleanTitle = t => t
|
|
378
|
+
.replace(/^[\d.]+\s*/, '')
|
|
379
|
+
.replace(/^Phase\s*\d+\s*:?\s*/i, '')
|
|
380
|
+
.replace(/[📑☕⚙️🐍🐹🎯🔒✅❌]/g, '')
|
|
381
|
+
.replace(/<[^>]+>/g, '')
|
|
382
|
+
.trim();
|
|
383
|
+
|
|
384
|
+
videos.forEach((v, i) => {
|
|
385
|
+
const c = classify(v);
|
|
386
|
+
v.index = i + 1;
|
|
387
|
+
v.form = c.form;
|
|
388
|
+
v.formWhy = c.why;
|
|
389
|
+
v.evidence = c.ev;
|
|
390
|
+
v.artefacts = c.artefacts;
|
|
391
|
+
|
|
392
|
+
// Title from the DEEPEST COMMON ANCESTOR of the units, not from the first one.
|
|
393
|
+
// Otherwise a video holding five principles gets named after whichever came first.
|
|
394
|
+
const lcaNode = commonAncestor(v.units);
|
|
395
|
+
const lcaTitle = cleanTitle(lcaNode.title || '');
|
|
396
|
+
let title;
|
|
397
|
+
if (lcaTitle && !lcaNode.isRoot && lcaTitle.length <= 48) {
|
|
398
|
+
title = lcaTitle; // a tight, meaningful ancestor
|
|
399
|
+
} else {
|
|
400
|
+
// units span a whole file: name the video after its own content range,
|
|
401
|
+
// ignoring container headings that carry almost no narration
|
|
402
|
+
const ts = v.units
|
|
403
|
+
.map(u => ({ t: cleanTitle(u.title), w: u.own }))
|
|
404
|
+
.filter(x => x.t && x.w > 5 && x.t.length <= 56)
|
|
405
|
+
.map(x => x.t.length > 40 ? x.t.slice(0, 38) + '…' : x.t);
|
|
406
|
+
if (!ts.length) title = lcaTitle ? lcaTitle.slice(0, 40) + '…' : `Part ${i + 1}`;
|
|
407
|
+
else if (ts.length === 1) title = ts[0];
|
|
408
|
+
else if (ts.length === 2) title = ts.join(' & ');
|
|
409
|
+
else title = `${ts[0]} → ${ts[ts.length - 1]}`;
|
|
410
|
+
}
|
|
411
|
+
v.title = title;
|
|
412
|
+
v.coveredHeading = lcaNode.isFile ? lcaNode.src : (lcaNode.title || '');
|
|
413
|
+
|
|
414
|
+
v.seconds = Math.round(toSec(v.words));
|
|
415
|
+
v.minutes = +(v.seconds / 60).toFixed(1);
|
|
416
|
+
|
|
417
|
+
const expanded = v.units.flatMap(u => secsOf(u).map(s => ({
|
|
418
|
+
src: s.src, title: s.title, narratable: s.narratable || 0,
|
|
419
|
+
line: s.section ? s.section.line + 1 : null, // 1-based inclusive span
|
|
420
|
+
endLine: s.section ? s.section.endLine : null,
|
|
421
|
+
})));
|
|
422
|
+
const ownOnly = (v.ownOnly || []).map(n => ({
|
|
423
|
+
src: n.src, title: n.title, narratable: n.own,
|
|
424
|
+
line: n.section ? n.section.line + 1 : null,
|
|
425
|
+
endLine: n.section ? n.section.endLine : null,
|
|
426
|
+
}));
|
|
427
|
+
// Section identity is (src, line): a heading's line number is unique within a
|
|
428
|
+
// file, so two same-title headings ("Analogy", "Appendix") stay distinct.
|
|
429
|
+
// Keying on src||title collapsed them — that was the lossy-identity bug.
|
|
430
|
+
const seen = new Set();
|
|
431
|
+
v.sections = expanded.concat(ownOnly).filter(s => {
|
|
432
|
+
const k = s.src + ':' + s.line;
|
|
433
|
+
if (seen.has(k)) return false;
|
|
434
|
+
seen.add(k); return true;
|
|
435
|
+
});
|
|
436
|
+
v.sources = Array.from(new Set(v.sections.map(s => s.src)));
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/* =============================================================================
|
|
440
|
+
7. Coverage assertion
|
|
441
|
+
========================================================================== */
|
|
442
|
+
const covered = new Set();
|
|
443
|
+
videos.forEach(v => v.sections.forEach(s => covered.add(s.src + ':' + s.line)));
|
|
444
|
+
const coverage = allSections.map(s => {
|
|
445
|
+
const line1 = s.line + 1; // v.sections stores 1-based line
|
|
446
|
+
const owner = videos.find(v => v.sections.some(x => x.src === s.src && x.line === line1));
|
|
447
|
+
const idxOwner = overlaps.find(o => o.src === s.src && o.section === s.title);
|
|
448
|
+
return {
|
|
449
|
+
src: s.src, title: s.title, line: line1, narratable: s.narratable,
|
|
450
|
+
video: owner ? owner.index : null,
|
|
451
|
+
excluded: idxOwner ? `index pointer -> ${idxOwner.child}/` : null,
|
|
452
|
+
reason: idxOwner ? 'summary of a dedicated source; that source is the video' : null,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
// Only sections that actually CARRY content can be orphaned. A container
|
|
456
|
+
// heading with no prose of its own is structure, not an uncovered topic.
|
|
457
|
+
const contentCoverage = coverage.filter(c => c.narratable > 0);
|
|
458
|
+
const structuralCoverage = coverage.filter(c => c.narratable === 0);
|
|
459
|
+
const orphans = contentCoverage.filter(c => !c.video && !c.excluded);
|
|
460
|
+
|
|
461
|
+
/* =============================================================================
|
|
462
|
+
8. Emit
|
|
463
|
+
========================================================================== */
|
|
464
|
+
const totalSec = videos.reduce((a, v) => a + v.seconds, 0);
|
|
465
|
+
// hard invariant: nothing may exceed the cap
|
|
466
|
+
const capViolations = videos.filter(v => v.seconds > CAP_SEC);
|
|
467
|
+
const plan = {
|
|
468
|
+
generatedFrom: sourceDir,
|
|
469
|
+
model: { wpm: CFG.wpm, expansion: CFG.expansion, maxVideoMin: CFG.maxMin, minVideoMin: CFG.minMin },
|
|
470
|
+
sourceStats: {
|
|
471
|
+
markdownFiles: mdFiles.length,
|
|
472
|
+
codeFiles: codeFiles.length,
|
|
473
|
+
markdownWords: mdFiles.reduce((a, f) => a + readFileSync(f.path, 'utf8').split(/\s+/).length, 0),
|
|
474
|
+
narratableWords: allSections.reduce((a, s) => a + s.narratable, 0),
|
|
475
|
+
},
|
|
476
|
+
totals: {
|
|
477
|
+
videos: videos.length,
|
|
478
|
+
seriesMinutes: +(totalSec / 60).toFixed(1),
|
|
479
|
+
longestVideoMin: Math.max(...videos.map(v => v.minutes)),
|
|
480
|
+
capMinutes: CFG.maxMin,
|
|
481
|
+
rawNarrationMinutes: +(allSections.reduce((a, s) => a + s.narratable, 0) / CFG.wpm).toFixed(1),
|
|
482
|
+
},
|
|
483
|
+
excluded: overlaps,
|
|
484
|
+
parserDiag: parseDiag,
|
|
485
|
+
coverage,
|
|
486
|
+
orphans,
|
|
487
|
+
videos: videos.map(v => ({
|
|
488
|
+
index: v.index, title: v.title, form: v.form, formWhy: v.formWhy,
|
|
489
|
+
minutes: v.minutes, seconds: v.seconds, words: v.words,
|
|
490
|
+
sources: v.sources, artefacts: v.artefacts, evidence: v.evidence,
|
|
491
|
+
sections: v.sections.map(s => `${s.src} :: ${s.title} @L${s.line}`),
|
|
492
|
+
sectionSpans: v.sections.map(s => ({ id: `${s.src} :: ${s.title} @L${s.line}`, src: s.src, title: s.title, line: s.line, endLine: s.endLine })),
|
|
493
|
+
})),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/* A plan records the model that produced it, and video INDICES are keyed by that
|
|
497
|
+
split. Re-planning with a different cap produces a different number of videos, so
|
|
498
|
+
any enrichment (titles/skips, keyed by index) silently stops pointing at the video
|
|
499
|
+
it was written for. That is a data-loss hazard, not a cosmetic mismatch, so it has
|
|
500
|
+
to be opted into rather than discovered later. */
|
|
501
|
+
const prevPlanPath = join(CFG.outDir, 'plan.json');
|
|
502
|
+
const enrichedPath = join(CFG.outDir, 'plan.enriched.json');
|
|
503
|
+
if (existsSync(prevPlanPath)) {
|
|
504
|
+
let prev = null;
|
|
505
|
+
try { prev = JSON.parse(readFileSync(prevPlanPath, 'utf8')); } catch { /* unreadable: treat as none */ }
|
|
506
|
+
const prevCap = prev && prev.model ? prev.model.maxVideoMin : null;
|
|
507
|
+
const prevCount = prev && Array.isArray(prev.videos) ? prev.videos.length : null;
|
|
508
|
+
const capChanged = prevCap != null && Math.abs(prevCap - CFG.maxMin) > 1e-9;
|
|
509
|
+
const countChanged = prevCount != null && prevCount !== videos.length;
|
|
510
|
+
if (capChanged || countChanged) {
|
|
511
|
+
const why = [];
|
|
512
|
+
if (capChanged) why.push(`cap ${prevCap} min -> ${CFG.maxMin} min`);
|
|
513
|
+
if (countChanged) why.push(`${prevCount} videos -> ${videos.length} videos`);
|
|
514
|
+
const enriched = existsSync(enrichedPath);
|
|
515
|
+
const msg = `[planner] existing plan in ${CFG.outDir} differs: ${why.join(', ')}`;
|
|
516
|
+
if (enriched) {
|
|
517
|
+
if (!CFG.force) {
|
|
518
|
+
console.error(`\n${msg}`);
|
|
519
|
+
console.error(' plan.enriched.json is present, and enrichment is keyed by video INDEX —');
|
|
520
|
+
console.error(' re-planning would silently repoint every curated title at a different video.');
|
|
521
|
+
console.error(' Re-run with --force to overwrite, or use a different --out to compare side by side.');
|
|
522
|
+
process.exit(4);
|
|
523
|
+
}
|
|
524
|
+
console.error(`\n${msg}`);
|
|
525
|
+
console.error(' --force given: overwriting an enriched plan. Re-run apply-enrichment afterwards.');
|
|
526
|
+
} else if (capChanged) {
|
|
527
|
+
console.error(`\n${msg}`);
|
|
528
|
+
console.error(' (no plan.enriched.json yet, so nothing is lost — overwriting.)');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* Scratch is not a throwaway: one 90-video series carries hundreds of MB of WAVs and
|
|
534
|
+
MP4s. If the scratch root sits inside a git work tree that does not ignore it, the
|
|
535
|
+
next `git add -A` sweeps it in. Verify the ignore rule; append it only if missing. */
|
|
536
|
+
function ensureScratchIgnored(outDir) {
|
|
537
|
+
const target = dirname(outDir); // the `out/` itself, not the per-series dir
|
|
538
|
+
try {
|
|
539
|
+
const root = execFileSync('git', ['-C', target, 'rev-parse', '--show-toplevel'],
|
|
540
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
541
|
+
if (!root) return;
|
|
542
|
+
try {
|
|
543
|
+
execFileSync('git', ['-C', root, 'check-ignore', '-q', target], { stdio: 'ignore' });
|
|
544
|
+
return; // already ignored — stay silent
|
|
545
|
+
} catch { /* not ignored: fall through */ }
|
|
546
|
+
if (target === root) return; // scratch root is the repo root; leave it alone
|
|
547
|
+
const rel = relative(root, target).split(sep).join('/');
|
|
548
|
+
const gi = join(root, '.gitignore');
|
|
549
|
+
const before = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
550
|
+
const already = before.split('\n').some((l) => {
|
|
551
|
+
const t = l.trim().replace(/\/$/, '');
|
|
552
|
+
return t === rel || t === `/${rel}`;
|
|
553
|
+
});
|
|
554
|
+
if (already) return; // ignored, just not by git's checker
|
|
555
|
+
const pad = !before || before.endsWith('\n') ? '' : '\n';
|
|
556
|
+
writeFileSync(gi, `${before}${pad}\n# explainer-video scratch — grows to hundreds of MB\n/${rel}/\n`);
|
|
557
|
+
console.log(`[gitignore] added /${rel}/ to ${relative(process.cwd(), gi) || '.gitignore'}`
|
|
558
|
+
+ ' (scratch is large; it must not be committable)');
|
|
559
|
+
} catch { /* no git, or not a work tree — nothing to ignore */ }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
mkdirSync(CFG.outDir, { recursive: true });
|
|
563
|
+
ensureScratchIgnored(CFG.outDir);
|
|
564
|
+
writeFileSync(join(CFG.outDir, 'plan.json'), JSON.stringify(plan, null, 2));
|
|
565
|
+
|
|
566
|
+
/* ---- human-readable report ---- */
|
|
567
|
+
const L = [];
|
|
568
|
+
L.push(`# ${CFG.title} — video series plan`, '');
|
|
569
|
+
L.push(`Source: \`${sourceDir}\``);
|
|
570
|
+
L.push(`Markdown: ${plan.sourceStats.markdownFiles} files, ${plan.sourceStats.markdownWords} words · Code: ${plan.sourceStats.codeFiles} files`);
|
|
571
|
+
L.push(`Narratable: ${plan.sourceStats.narratableWords} words → **${plan.totals.rawNarrationMinutes} min** of pure narration`);
|
|
572
|
+
L.push('');
|
|
573
|
+
L.push(`Model: ${CFG.wpm} wpm × ${CFG.expansion} overhead · cap **${CFG.maxMin} min/video**`);
|
|
574
|
+
L.push('');
|
|
575
|
+
L.push(`## Result: ${plan.totals.videos} videos, ${plan.totals.seriesMinutes} min total (longest ${plan.totals.longestVideoMin} min)`, '');
|
|
576
|
+
L.push('| # | Video | Form | Min | Sources | Code |');
|
|
577
|
+
L.push('|---|---|---|---|---|---|');
|
|
578
|
+
videos.forEach(v => {
|
|
579
|
+
L.push(`| ${v.index} | ${v.title} | \`${v.form}\` | ${v.minutes} | ${v.sources.join(', ')} | ${v.artefacts.length} |`);
|
|
580
|
+
});
|
|
581
|
+
L.push('');
|
|
582
|
+
L.push('### Form evidence', '');
|
|
583
|
+
videos.forEach(v => L.push(`- **${v.index}. ${v.title}** → \`${v.form}\` — ${v.formWhy} ${JSON.stringify(v.evidence)}`));
|
|
584
|
+
if (overlaps.length) {
|
|
585
|
+
L.push('', '### Excluded (navigation headings, not content)', '');
|
|
586
|
+
overlaps.forEach(o => L.push(`- \`${o.src}\` :: "${o.section}" (${o.ownWords}w) → ${o.child}`));
|
|
587
|
+
}
|
|
588
|
+
if (capViolations.length) {
|
|
589
|
+
L.push('', `**⚠️ ${capViolations.length} video(s) exceed the ${CFG.maxMin}-min cap:**`);
|
|
590
|
+
capViolations.forEach(v => L.push(`- ${v.index}. ${v.title} — ${v.minutes} min`));
|
|
591
|
+
}
|
|
592
|
+
L.push('', '### Parser self-check (did we actually SEE every heading?)', '');
|
|
593
|
+
parseDiag.forEach(d => {
|
|
594
|
+
const delta = d.rawHeadingLines - d.parsedHeadings;
|
|
595
|
+
L.push(`- \`${d.file}\`: ${d.rawHeadingLines} heading-like lines, ${d.parsedHeadings} parsed${delta ? ` (delta ${delta} — expected: code comments inside fences)` : ''}`);
|
|
596
|
+
});
|
|
597
|
+
L.push('', '### Coverage', '');
|
|
598
|
+
L.push(`${coverage.length} headings · ${contentCoverage.length} carry content · ${structuralCoverage.length} are structural containers`);
|
|
599
|
+
L.push(`${contentCoverage.filter(c => c.video).length} assigned · ${contentCoverage.filter(c => c.excluded).length} excluded as navigation · **${orphans.length} orphaned**`);
|
|
600
|
+
if (orphans.length) {
|
|
601
|
+
L.push('', '**ORPHANS (content nothing covers):**');
|
|
602
|
+
orphans.forEach(o => L.push(`- ⚠️ ${o.src} :: ${o.title} @L${o.line} (${o.narratable} narratable words)`));
|
|
603
|
+
} else {
|
|
604
|
+
L.push('', '✅ No orphans — every content-bearing section is either assigned to a video or is a navigation heading.');
|
|
605
|
+
}
|
|
606
|
+
writeFileSync(join(CFG.outDir, 'plan.md'), L.join('\n') + '\n');
|
|
607
|
+
|
|
608
|
+
/* ---- console summary ----
|
|
609
|
+
Print the WHOLE plan, not a sample. The first 12 lines are the title, stats and the
|
|
610
|
+
table header — i.e. slicing here printed the header and zero video rows, which
|
|
611
|
+
quietly contradicted this tool's own rule that every number is printed so the plan
|
|
612
|
+
can be argued with. A big plan is long; hiding rows is worse than being long. */
|
|
613
|
+
console.log(L.join('\n'));
|
|
614
|
+
console.log(`\n[out] ${join(CFG.outDir, 'plan.json')}`);
|
|
615
|
+
console.log(`[out] ${join(CFG.outDir, 'plan.md')}`);
|
|
616
|
+
if (orphans.length) { console.log(`\n⚠️ ${orphans.length} orphaned section(s)`); process.exitCode = 2; }
|