@openvidstudio/mcp-server 1.3.1 → 1.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.
Files changed (57) hide show
  1. package/dist/canvasExplainer.d.ts +198 -0
  2. package/dist/canvasExplainer.js +661 -0
  3. package/dist/canvasValidate.d.ts +76 -0
  4. package/dist/canvasValidate.js +263 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +2 -0
  7. package/dist/install.d.ts +63 -0
  8. package/dist/install.js +225 -0
  9. package/dist/lessonArc.d.ts +68 -0
  10. package/dist/lessonArc.js +188 -0
  11. package/dist/server.d.ts +1 -1
  12. package/dist/server.js +1 -1
  13. package/dist/stdio.js +12 -4
  14. package/dist/tools/checkScript.d.ts +31 -1
  15. package/dist/tools/checkScript.js +183 -6
  16. package/dist/tools/fitBeats.d.ts +69 -0
  17. package/dist/tools/fitBeats.js +229 -0
  18. package/dist/tools/generateNarration.d.ts +19 -0
  19. package/dist/tools/generateNarration.js +48 -5
  20. package/dist/tools/index.js +6 -0
  21. package/dist/tools/initProject.js +6 -1
  22. package/dist/tools/preflight.js +20 -1
  23. package/dist/tools/renderCanvasExplainer.d.ts +2 -0
  24. package/dist/tools/renderCanvasExplainer.js +56 -0
  25. package/dist/tools/renderVideo.d.ts +3 -0
  26. package/dist/tools/renderVideo.js +13 -4
  27. package/dist/tools/validateBeats.d.ts +2 -0
  28. package/dist/tools/validateBeats.js +5 -5
  29. package/dist/tools/validateCanvasExplainer.d.ts +2 -0
  30. package/dist/tools/validateCanvasExplainer.js +35 -0
  31. package/dist/tools/validateScenes.d.ts +6 -0
  32. package/dist/tools/validateScenes.js +27 -6
  33. package/dist/tools/validateScenesRender.d.ts +8 -0
  34. package/dist/tools/validateScenesRender.js +31 -0
  35. package/dist/tools/writeDescription.d.ts +6 -0
  36. package/dist/tools/writeDescription.js +64 -2
  37. package/docs/API.md +291 -0
  38. package/docs/NARRATION.md +12 -5
  39. package/docs/PIPELINE.md +46 -0
  40. package/docs/SCRIPT.md +63 -3
  41. package/docs/SUPERNOVA.md +53 -15
  42. package/docs/TOOLS.md +4 -1
  43. package/package.json +4 -4
  44. package/templates/default/openvidstudio-core/package.json +9 -9
  45. package/templates/default/openvidstudio-stem/package.json +5 -5
  46. package/templates/default/openvidstudio-stem/src/ComplexityCurve.tsx +108 -0
  47. package/templates/default/openvidstudio-stem/src/Derivation.tsx +334 -0
  48. package/templates/default/openvidstudio-stem/src/Equation.tsx +3 -3
  49. package/templates/default/openvidstudio-stem/src/GraphViz.tsx +249 -0
  50. package/templates/default/openvidstudio-stem/src/LessonCaptions.tsx +135 -0
  51. package/templates/default/openvidstudio-stem/src/MatrixGrid.tsx +187 -0
  52. package/templates/default/openvidstudio-stem/src/Orbit.tsx +241 -0
  53. package/templates/default/openvidstudio-stem/src/TreeViz.tsx +241 -0
  54. package/templates/default/openvidstudio-stem/src/VectorField.tsx +231 -0
  55. package/templates/default/openvidstudio-stem/src/Wave.tsx +234 -0
  56. package/templates/default/openvidstudio-stem/src/index.ts +21 -0
  57. package/templates/default/package.json +10 -10
@@ -296,6 +296,8 @@ async function runIncrementalRender(input, ctx) {
296
296
  totalFrames,
297
297
  };
298
298
  }
299
+ const SUPERNOVA_BRAND_LOCK_SKIP = "Brand-lock gate skipped: this is a supernova project, and a lesson has no brand to extract " +
300
+ "(its design is supernovaDesignSystem).";
299
301
  /**
300
302
  * The synchronous half of render_video: validate input and the brand-lock gate, throwing
301
303
  * immediately on anything wrong, before any real work (a render that will take 40+ minutes,
@@ -313,13 +315,14 @@ function prepareRenderVideo(input) {
313
315
  const outPathRel = input.outPath ?? path.join("output", `${videoName}.mp4`);
314
316
  (0, util_1.sanitizeRelativeOutPath)(projectRoot, outPathRel, "outPath");
315
317
  const brandLocked = fs.existsSync(path.join(projectRoot, "src", "brand.ts"));
316
- if (!brandLocked && input.skipBrandLock !== true) {
318
+ const brandLockSkipReason = !brandLocked && (0, config_1.productionMode)((0, config_1.loadConfig)(projectRoot)) === "supernova" ? SUPERNOVA_BRAND_LOCK_SKIP : undefined;
319
+ if (!brandLocked && input.skipBrandLock !== true && brandLockSkipReason === undefined) {
317
320
  throw new Error(`Brand-lock gate: no src/brand.ts found in this project. Run extract_brand with ` +
318
321
  `{ sourceRepo: "${projectRoot}" } before rendering. It writes src/brand.ts from the repo being filmed. ` +
319
322
  "If this project deliberately has no brand to extract, pass skipBrandLock: true.");
320
323
  }
321
324
  fs.mkdirSync(path.dirname(path.join(projectRoot, outPathRel)), { recursive: true });
322
- return { videoName, projectRoot, compositionId, outPathRel, brandLocked };
325
+ return { videoName, projectRoot, compositionId, outPathRel, brandLocked, ...(brandLockSkipReason ? { brandLockSkipReason } : {}) };
323
326
  }
324
327
  /**
325
328
  * Records what this render actually did next to what was asked: which beats were rendered
@@ -349,7 +352,12 @@ async function runRenderVideo(input, opts = {}) {
349
352
  return result;
350
353
  }
351
354
  async function renderVideoUnrecorded(input, opts) {
352
- const { videoName, projectRoot, compositionId, outPathRel, brandLocked } = prepareRenderVideo(input);
355
+ const prepared = prepareRenderVideo(input);
356
+ const result = await renderPrepared(input, opts, prepared);
357
+ return prepared.brandLockSkipReason ? { ...result, brandLockSkipReason: prepared.brandLockSkipReason } : result;
358
+ }
359
+ async function renderPrepared(input, opts, prepared) {
360
+ const { videoName, projectRoot, compositionId, outPathRel, brandLocked } = prepared;
353
361
  const started = Date.now();
354
362
  if (input.incremental === true) {
355
363
  return runIncrementalRender(input, {
@@ -404,7 +412,8 @@ function registerRenderVideo(server) {
404
412
  "to tell progress from a hang. BRAND-LOCK GATE: refuses to run unless src/brand.ts exists (written " +
405
413
  "by extract_brand), so a video never ships wearing openvidstudio's own default look by accident. " +
406
414
  "Run extract_brand against the repo being filmed first; pass skipBrandLock: true only when the " +
407
- "project deliberately has no brand to extract. Returns the render's captured stdout/stderr, how " +
415
+ "project deliberately has no brand to extract. A supernova project (production.mode in " +
416
+ "openvidstudio.config.json) skips the gate on its own and says why in brandLockSkipReason. Returns the render's captured stdout/stderr, how " +
408
417
  "long it took, and brandLocked (whether src/brand.ts existed at render time). Pass incremental to render "
409
418
  + "each beat to its own cached segment under output/segments/<videoName>/ and concatenate them with "
410
419
  + "ffmpeg under stream copy, re-rendering only the beats whose inputs actually changed. A segment is "
@@ -8,6 +8,8 @@ export interface ValidateBeatsResult {
8
8
  */
9
9
  warnings: string[];
10
10
  }
11
+ export declare const MIN_PACE = 2.3;
12
+ export declare const MAX_PACE = 2.9;
11
13
  /** SCRIPT.md's reading-time budget for onScreenText on a narration:"none" beat. */
12
14
  export declare const MAX_READING_WORDS_PER_SEC = 3.5;
13
15
  export declare const READING_OVERHEAD_SEC = 0.8;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.READING_OVERHEAD_SEC = exports.MAX_READING_WORDS_PER_SEC = void 0;
3
+ exports.READING_OVERHEAD_SEC = exports.MAX_READING_WORDS_PER_SEC = exports.MAX_PACE = exports.MIN_PACE = void 0;
4
4
  exports.wordCount = wordCount;
5
5
  exports.voPaceCheck = voPaceCheck;
6
6
  exports.validateBeatsLogic = validateBeatsLogic;
@@ -16,8 +16,8 @@ const EM_DASH = "—";
16
16
  const RHYTHM_MIN_BEATS = 4;
17
17
  /** The longest beat should be at least this many times the shortest, or the shots read as evenly spaced ticks. */
18
18
  const RHYTHM_MIN_SPREAD = 2.5;
19
- const MIN_PACE = 2.3;
20
- const MAX_PACE = 2.9;
19
+ exports.MIN_PACE = 2.3;
20
+ exports.MAX_PACE = 2.9;
21
21
  /** SCRIPT.md's reading-time budget for onScreenText on a narration:"none" beat. */
22
22
  exports.MAX_READING_WORDS_PER_SEC = 3.5;
23
23
  exports.READING_OVERHEAD_SEC = 0.8;
@@ -45,8 +45,8 @@ function wordCount(text) {
45
45
  function voPaceCheck(vo, seconds) {
46
46
  const words = wordCount(vo);
47
47
  const pace = words / seconds;
48
- const message = pace < MIN_PACE || pace > MAX_PACE
49
- ? `VO pace ${pace.toFixed(2)} words/sec is outside the ${MIN_PACE}-${MAX_PACE} words/sec ` +
48
+ const message = pace < exports.MIN_PACE || pace > exports.MAX_PACE
49
+ ? `VO pace ${pace.toFixed(2)} words/sec is outside the ${exports.MIN_PACE}-${exports.MAX_PACE} words/sec ` +
50
50
  `budget (${words} words over ${seconds.toFixed(2)}s, per SCRIPT.md)`
51
51
  : null;
52
52
  return { words, pace, message };
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerValidateCanvasExplainer(server: McpServer): void;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerValidateCanvasExplainer = registerValidateCanvasExplainer;
4
+ const zod_1 = require("zod");
5
+ const canvasValidate_1 = require("../canvasValidate");
6
+ const mcp_1 = require("./mcp");
7
+ function registerValidateCanvasExplainer(server) {
8
+ server.registerTool("validate_canvas_explainer", {
9
+ title: "Check a canvas explainer for problems that render without erroring",
10
+ description: "What validate_scenes does for a Remotion composition, for a canvas explainer (see render_canvas_explainer). Loads the HTML " +
11
+ "in headless Chromium and samples frames through window.seek(t). Checks: purity, three times drawn twice with another time " +
12
+ "between, the frames must match (error); text size, by intercepting fillText and strokeText: labels 28px, headlines 44px and " +
13
+ "key numbers 64px at 720 on the shorter side, scaled to the canvas (warning below, error below half; captions drawn through " +
14
+ "window.CAPTIONS are exempt); safe areas, on a tall canvas no text in the top 14% or bottom 22% except captions (warning); the " +
15
+ "lesson arc through the same code as validate_scenes, the first frame with anything drawn beyond the background and the captions " +
16
+ "(diagram-late: warning after 15 s, error after 25 s) and any run of frames that do not change longer than 8 s " +
17
+ "(static-stretch, warning); the sound, by scheduling window.scoreSound into an offline context: the most source nodes " +
18
+ "playing at once (warning above 3) and the peak level (warning at clipping); and Math.random in the source (warning). " +
19
+ "Findings have validate_scenes' shape (beatId, scene, severity, check, message). Pass videoName to use the project's beats " +
20
+ "and captions as render_canvas_explainer does. Returns ok, the findings, the length, the smallest text size and the sound figures.",
21
+ inputSchema: {
22
+ projectRoot: zod_1.z.string().optional(),
23
+ htmlPath: zod_1.z.string().min(1),
24
+ width: zod_1.z.number().int().positive().optional().describe("Default 1280."),
25
+ height: zod_1.z.number().int().positive().optional().describe("Default 720. Use 720 wide by 1280 high for a Reel."),
26
+ fps: zod_1.z.number().positive().optional(),
27
+ videoName: zod_1.z.string().optional(),
28
+ audio: zod_1.z.boolean().optional().describe("Judge the sound too. Default true."),
29
+ textScale: zod_1.z.number().positive().optional().describe("Multiply the text minimums by this instead of min(width, height) / 720."),
30
+ diagramWarnSeconds: zod_1.z.number().positive().optional(),
31
+ diagramErrorSeconds: zod_1.z.number().positive().optional(),
32
+ staticSeconds: zod_1.z.number().positive().optional(),
33
+ },
34
+ }, async (input) => (0, mcp_1.runTool)("validate_canvas_explainer", () => (0, canvasValidate_1.validateCanvasExplainer)(input)));
35
+ }
@@ -27,6 +27,12 @@ export interface ValidateScenesInput {
27
27
  compositionId?: string;
28
28
  /** With render: limit the measurement to these beats. Static checks always cover every beat. */
29
29
  beatIds?: string[];
30
+ /** Lessons, with render: warn when the first drawn element is later than this many seconds. Default 15. */
31
+ diagramWarnSeconds?: number;
32
+ /** Lessons, with render: an error when the first drawn element is later than this. Default 25. */
33
+ diagramErrorSeconds?: number;
34
+ /** Lessons, with render: warn when nothing changes for longer than this. Default 8. */
35
+ staticSeconds?: number;
30
36
  }
31
37
  export interface SceneFinding {
32
38
  beatId: string;
@@ -44,11 +44,19 @@ const path = __importStar(require("node:path"));
44
44
  const zod_1 = require("zod");
45
45
  const util_1 = require("../util");
46
46
  const config_1 = require("../config");
47
+ const lessonArc_1 = require("../lessonArc");
47
48
  const capture_1 = require("@openvidstudio/capture");
48
49
  const mcp_1 = require("./mcp");
49
50
  const facts_1 = require("../facts");
50
51
  const renderStills_1 = require("./renderStills");
51
52
  const validateScenesRender_1 = require("./validateScenesRender");
53
+ function arcThresholds(input) {
54
+ return {
55
+ diagramWarnSeconds: input.diagramWarnSeconds ?? lessonArc_1.DEFAULT_ARC_THRESHOLDS.diagramWarnSeconds,
56
+ diagramErrorSeconds: input.diagramErrorSeconds ?? lessonArc_1.DEFAULT_ARC_THRESHOLDS.diagramErrorSeconds,
57
+ staticSeconds: input.staticSeconds ?? lessonArc_1.DEFAULT_ARC_THRESHOLDS.staticSeconds,
58
+ };
59
+ }
52
60
  /**
53
61
  * Reads the `<asset>.settle.json` sidecar capture_screenshot/capture_screen_recording
54
62
  * write next to their output (see @openvidstudio/capture's writeSettleSidecar) and
@@ -298,8 +306,9 @@ function staticPass(input) {
298
306
  });
299
307
  }
300
308
  }
301
- // Motion. A frozen frame is a QC fail in STYLE.md.
302
- if (scales.length >= 2 && new Set(scales).size === 1) {
309
+ // Motion. A frozen frame is a QC fail in STYLE.md. A lesson scene is flat on purpose (a Void with a
310
+ // Spark, a graph, an equation) and need not use CinematicScene, so the camera checks are product-demo rules.
311
+ if (!lesson && scales.length >= 2 && new Set(scales).size === 1) {
303
312
  const positions = new Set([...src.matchAll(/\bx:\s*(\d+),\s*y:\s*(\d+)/g)].map((m) => `${m[1]},${m[2]}`));
304
313
  if (positions.size <= 1) {
305
314
  findings.push({
@@ -310,7 +319,7 @@ function staticPass(input) {
310
319
  });
311
320
  }
312
321
  }
313
- if (scales.length < 2 && !usesSelfManagedCamera(src)) {
322
+ if (!lesson && scales.length < 2 && !usesSelfManagedCamera(src)) {
314
323
  findings.push({
315
324
  beatId: beat.id,
316
325
  scene: `${component}.tsx`,
@@ -411,6 +420,7 @@ async function runValidateScenesFull(input) {
411
420
  const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
412
421
  const videoDir = path.join(projectRoot, "src", "videos", videoName);
413
422
  const doc = JSON.parse(fs.readFileSync(path.join(videoDir, "beats.json"), "utf8"));
423
+ const lesson = isLesson(projectRoot, doc);
414
424
  let beats = doc.beats;
415
425
  if (input.beatIds && input.beatIds.length > 0) {
416
426
  const known = new Set(beats.map((b) => b.id));
@@ -429,7 +439,10 @@ async function runValidateScenesFull(input) {
429
439
  fps: doc.fps ?? 30,
430
440
  minimums: (0, validateScenesRender_1.readTypeMinimums)(videoDir),
431
441
  deliberateHolds: (0, validateScenesRender_1.readDeliberateHolds)(videoDir),
432
- glowBudget: isLesson(projectRoot, doc),
442
+ glowBudget: lesson,
443
+ // The arc is about the whole lesson, so a run over a few beats (beatIds) does not judge it.
444
+ arc: lesson && beats.length === doc.beats.length ? arcThresholds(input) : undefined,
445
+ lessonSeconds: Math.max(0, ...doc.beats.map((b) => b.start + b.duration)) / (doc.fps ?? 30),
433
446
  });
434
447
  const findings = [...result.findings, ...rendered.findings];
435
448
  const full = {
@@ -467,11 +480,16 @@ function registerValidateScenes(server) {
467
480
  "needs longer to read than the beat lasts (3.5 words/sec plus 0.8s, the same budget validate_beats " +
468
481
  "applies), beats whose frames never change, em dashes in on-screen copy, text colour against the " +
469
482
  "pixels rendered behind it (WCAG 4.5:1, 3:1 for large text), text outside the title-safe area, and " +
470
- "scene text that overlaps the chapter HUD. In a Supernova lesson (production mode supernova) two checks change: " +
483
+ "scene text that overlaps the chapter HUD. In a Supernova lesson (production mode supernova) the camera warnings (fewer than two keyframes, a camera that never moves) are dropped, " +
484
+ "and two checks change: " +
471
485
  "a number typed into a scene passes when the concept card's worked example (concept.json) or an Equation's tex " +
472
486
  "has it, and one that appears nowhere is still flagged, and render mode also counts the glowing elements in " +
473
487
  "sampled frames (Void + Spark allows one at a time): more than one is a warning, more than two an error, " +
474
- "except in a beat that holds a Nova. Needs stitch_composition to have run. A beat that cannot be mounted (a screen " +
488
+ "except in a beat that holds a Nova. Render mode in a lesson also judges its arc from the frames it " +
489
+ "already samples (and only when every beat is measured): diagram-late, the first sampled frame with anything " +
490
+ "drawn beyond the background and the captions (a warning after 15 s, an error after 25 s), and static-stretch, " +
491
+ "a run of sampled frames that do not change, captions aside, for more than 8 s (a warning). The thresholds are " +
492
+ "the options diagramWarnSeconds, diagramErrorSeconds and staticSeconds. Needs stitch_composition to have run. A beat that cannot be mounted (a screen " +
475
493
  "recording played through OffthreadVideo) is reported as render-skipped, never as clean.",
476
494
  inputSchema: {
477
495
  projectRoot: zod_1.z.string().optional(),
@@ -479,6 +497,9 @@ function registerValidateScenes(server) {
479
497
  render: zod_1.z.boolean().optional(),
480
498
  compositionId: zod_1.z.string().optional(),
481
499
  beatIds: zod_1.z.array(zod_1.z.string()).optional(),
500
+ diagramWarnSeconds: zod_1.z.number().positive().optional().describe("Lessons, render mode: warn when the first drawn element is later than this (default 15)."),
501
+ diagramErrorSeconds: zod_1.z.number().positive().optional().describe("Lessons, render mode: error when the first drawn element is later than this (default 25)."),
502
+ staticSeconds: zod_1.z.number().positive().optional().describe("Lessons, render mode: warn when nothing changes for longer than this (default 8)."),
482
503
  },
483
504
  }, async (input) => (0, mcp_1.runTool)("validate_scenes", () => runValidateScenesFull(input)));
484
505
  }
@@ -1,3 +1,4 @@
1
+ import { type ArcThresholds } from "../lessonArc";
1
2
  import type { SceneFinding } from "./validateScenes";
2
3
  import { MAX_READING_WORDS_PER_SEC, READING_OVERHEAD_SEC } from "./validateBeats";
3
4
  /**
@@ -71,6 +72,13 @@ export interface RenderCheckInput {
71
72
  deliberateHolds?: string[];
72
73
  /** Count the glowing elements in each sampled frame against Supernova's one-at-a-time budget. Lessons only. */
73
74
  glowBudget?: boolean;
75
+ /**
76
+ * Judge the lesson arc (the diagram inside the first seconds, no long static stretch) from the frames sampled
77
+ * here, with these thresholds. Lessons only, and only when every beat of the lesson is measured.
78
+ */
79
+ arc?: ArcThresholds;
80
+ /** The lesson's length in seconds, which bounds a static stretch that runs to the end. */
81
+ lessonSeconds?: number;
74
82
  }
75
83
  export interface RenderCheckResult {
76
84
  findings: SceneFinding[];
@@ -54,6 +54,7 @@ const capture_1 = require("@openvidstudio/capture");
54
54
  const remotionBridge_1 = require("../remotionBridge");
55
55
  const sharp_1 = __importDefault(require("sharp"));
56
56
  const frameDiff_1 = require("../frameDiff");
57
+ const lessonArc_1 = require("../lessonArc");
57
58
  const util_1 = require("../util");
58
59
  const validateBeats_1 = require("./validateBeats");
59
60
  Object.defineProperty(exports, "MAX_READING_WORDS_PER_SEC", { enumerable: true, get: function () { return validateBeats_1.MAX_READING_WORDS_PER_SEC; } });
@@ -623,6 +624,13 @@ function judgeGlow(beat, samples, frames) {
623
624
  },
624
625
  ];
625
626
  }
627
+ /** Runs in the page: the boxes of the lesson's captions (LessonCaptions marks them), in frame pixels. */
628
+ function probeCaptionBoxes() {
629
+ return Array.from(document.querySelectorAll("[data-ovs-captions]")).map((el) => {
630
+ const r = el.getBoundingClientRect();
631
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
632
+ });
633
+ }
626
634
  function sampleFrames(beat) {
627
635
  const last = Math.max(0, beat.duration - 1);
628
636
  const frames = SAMPLE_FRACTIONS.map((f) => beat.start + Math.min(last, Math.round(beat.duration * f)));
@@ -645,6 +653,8 @@ async function runRenderChecks(input) {
645
653
  // Largest share of each brand colour seen in any sampled frame, for the palette check.
646
654
  const brandColors = brandHexColors(input.projectRoot);
647
655
  const brandSeen = new Map(brandColors.map((c) => [c, 0]));
656
+ const arcSamples = [];
657
+ let arcPrevious = null;
648
658
  try {
649
659
  const composition = await (0, remotionBridge_1.resolveComposition)(bundleDir, input.compositionId, remotionBrowser);
650
660
  browser = await (0, capture_1.launchChromium)();
@@ -682,6 +692,11 @@ async function runRenderChecks(input) {
682
692
  const shot = path.join(scratch, `${beat.id}-${i}.png`);
683
693
  await page.screenshot({ path: shot });
684
694
  shots.push(shot);
695
+ if (input.arc) {
696
+ const arc = await (0, lessonArc_1.measureArcFrame)(shot, await page.evaluate(probeCaptionBoxes), arcPrevious);
697
+ arcPrevious = arc.signature;
698
+ arcSamples.push({ time: frame / input.fps, beatId: beat.id, drawn: arc.drawn, changedFromPrevious: arc.changedFromPrevious });
699
+ }
685
700
  const shares = await measureFrame(shot, probes, brandColors);
686
701
  for (const [hex, share] of shares)
687
702
  brandSeen.set(hex, Math.max(brandSeen.get(hex) ?? 0, share));
@@ -720,6 +735,7 @@ async function runRenderChecks(input) {
720
735
  severity: "warning",
721
736
  message: `Render-based checks could not run for this beat, so it was NOT measured: ${skipped[skipped.length - 1].reason}`,
722
737
  });
738
+ arcPrevious = null;
723
739
  // The page may be wedged mid-mount; the next beat gets a fresh one.
724
740
  await page?.close().catch(() => undefined);
725
741
  page = null;
@@ -730,6 +746,21 @@ async function runRenderChecks(input) {
730
746
  // no frame to find the palette in, and saying it is missing would be a false alarm.
731
747
  if (measured > 0)
732
748
  findings.push(...judgeBrandPalette(brandSeen));
749
+ // A lesson that skipped a beat has a hole in its samples, and a hole reads as a static stretch or a late
750
+ // diagram that may not be there. Judged only when every beat was measured.
751
+ if (input.arc && measured === input.beats.length && measured > 0) {
752
+ const lessonSeconds = input.lessonSeconds ?? Math.max(...input.beats.map((b) => b.start + b.duration)) / input.fps;
753
+ for (const f of (0, lessonArc_1.judgeLessonArc)(arcSamples, lessonSeconds, input.arc)) {
754
+ findings.push({
755
+ beatId: f.beatId,
756
+ scene: f.beatId === "*" ? "beats.json" : `${(0, util_1.pascalCase)(f.beatId)}.tsx`,
757
+ source: "render",
758
+ check: f.check,
759
+ severity: f.severity,
760
+ message: f.message,
761
+ });
762
+ }
763
+ }
733
764
  }
734
765
  finally {
735
766
  await browser?.close().catch(() => undefined);
@@ -12,6 +12,12 @@ export interface WriteDescriptionResult {
12
12
  chapters: string[];
13
13
  /** Credit lines written, so a caller can show them without re-reading the file. */
14
14
  credits: string[];
15
+ /** Lesson with a practiceQuestion and an answer: the ANSWER.md written next to the description (the pinned comment). Absent otherwise. */
16
+ answerPath?: string;
17
+ /** Lesson with a practiceQuestion and an answer: the question put in the description. Absent otherwise. */
18
+ practiceQuestion?: string;
19
+ /** The config's `disclaimers` appended to a lesson description, each once, in order. Absent when none were. */
20
+ disclaimers?: string[];
15
21
  }
16
22
  /** YouTube lists chapters only when there are at least three and each is at least ten seconds. */
17
23
  export declare const MIN_CHAPTERS = 3;
@@ -137,6 +137,36 @@ function readBrief(projectRoot, videoName) {
137
137
  }
138
138
  return { logline, audience, feeling, constructedShots, ...(lesson ? { lesson: true } : {}) };
139
139
  }
140
+ /**
141
+ * A lesson's concept card (src/videos/<video>/concept.json) gives the practice question and its answer. Absence is
142
+ * normal: most lessons have no practice question, and then nothing here changes. It counts only when both the question
143
+ * and the answer are there, because a question with no answer to withhold is just a line in the narration.
144
+ */
145
+ function readPractice(projectRoot, videoName) {
146
+ const file = path.join(projectRoot, "src", "videos", videoName, "concept.json");
147
+ if (!fs.existsSync(file))
148
+ return undefined;
149
+ let card;
150
+ try {
151
+ card = JSON.parse(fs.readFileSync(file, "utf8"));
152
+ }
153
+ catch (err) {
154
+ throw new Error(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
155
+ }
156
+ const text = (v) => (typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined);
157
+ const question = text(card.practiceQuestion);
158
+ const answer = text(card.answer);
159
+ if (!question || !answer)
160
+ return undefined;
161
+ return { question, answer, working: text(card.answerWorking) };
162
+ }
163
+ /** The pinned comment: the answer, and one line of working when the card has it. */
164
+ function buildAnswer(practice) {
165
+ const lines = [`Answer: ${practice.answer}`];
166
+ if (practice.working)
167
+ lines.push(`Working: ${practice.working}`);
168
+ return lines.join("\n") + "\n";
169
+ }
140
170
  /**
141
171
  * Reads beats.json for the real beat list. Required, unlike brief.json: without it there is
142
172
  * nothing to ground the description in, which is the entire point of this tool.
@@ -293,7 +323,7 @@ function formatSeconds(frames, fps) {
293
323
  * any tool, so "drafting" here means template logic over real facts (brief fields, real beats),
294
324
  * never invented content.
295
325
  */
296
- function buildDescription(videoName, brief, beatsData, chapters, credits, lesson = false) {
326
+ function buildDescription(videoName, brief, beatsData, chapters, credits, lesson = false, practice, disclaimers = []) {
297
327
  const { fps, beats } = beatsData;
298
328
  const totalFrames = beats.reduce((sum, b) => sum + b.duration, 0);
299
329
  const lines = [`# ${videoName}`, ""];
@@ -341,6 +371,14 @@ function buildDescription(videoName, brief, beatsData, chapters, credits, lesson
341
371
  // Replaces the sample-content disclosure: everything in a lesson is drawn for it.
342
372
  lines.push("This video is a constructed explanation, not footage.");
343
373
  lines.push("");
374
+ if (practice) {
375
+ lines.push("## Practice question");
376
+ lines.push("");
377
+ lines.push(practice.question);
378
+ lines.push("");
379
+ lines.push("Drop your answer in the comments.");
380
+ lines.push("");
381
+ }
344
382
  }
345
383
  else if (constructedShots.length > 0) {
346
384
  lines.push("## Sample content used in this video");
@@ -367,6 +405,13 @@ function buildDescription(videoName, brief, beatsData, chapters, credits, lesson
367
405
  for (const c of credits)
368
406
  lines.push(`- ${c}`);
369
407
  lines.push("");
408
+ // Last, each line once: a line the description already carries on its own is not repeated.
409
+ const body = new Set(lines.map((l) => l.trim()));
410
+ const added = disclaimers.filter((d) => !body.has(d));
411
+ for (const d of added)
412
+ lines.push(d);
413
+ if (added.length > 0)
414
+ lines.push("");
370
415
  return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
371
416
  }
372
417
  /**
@@ -382,7 +427,11 @@ function runWriteDescription(input) {
382
427
  const chapters = buildChapters(beatsData.beats, beatsData.fps);
383
428
  const credits = buildCredits(projectRoot, videoName, beatsData.beats);
384
429
  const lesson = brief?.lesson === true || (0, config_1.productionMode)((0, config_1.loadConfig)(projectRoot)) === "supernova";
385
- const content = buildDescription(videoName, brief, beatsData, chapters, credits, lesson);
430
+ const practice = lesson ? readPractice(projectRoot, videoName) : undefined;
431
+ const disclaimers = lesson
432
+ ? [...new Set(((0, config_1.loadConfig)(projectRoot).disclaimers ?? []).map((d) => d.trim()).filter((d) => d.length > 0))]
433
+ : [];
434
+ const content = buildDescription(videoName, brief, beatsData, chapters, credits, lesson, practice, disclaimers);
386
435
  const outPath = path.join(projectRoot, "output", `${videoName}-DESCRIPTION.md`);
387
436
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
388
437
  fs.writeFileSync(outPath, content, "utf8");
@@ -406,12 +455,19 @@ function runWriteDescription(input) {
406
455
  `passing result, so the incomplete file was removed instead of being reported as written.`);
407
456
  }
408
457
  }
458
+ let answerPath;
459
+ if (practice) {
460
+ answerPath = path.join(projectRoot, "output", `${videoName}-ANSWER.md`);
461
+ fs.writeFileSync(answerPath, buildAnswer(practice), "utf8");
462
+ }
409
463
  return {
410
464
  written: true,
411
465
  path: outPath,
412
466
  sampleContentDisclosed: constructedShots.map((s) => s.beatId),
413
467
  chapters: chapters.chapters.map((c) => `${chapterTimestamp(c.seconds)} ${c.label}`),
414
468
  credits,
469
+ ...(practice && answerPath ? { answerPath, practiceQuestion: practice.question } : {}),
470
+ ...(disclaimers.length > 0 ? { disclaimers } : {}),
415
471
  };
416
472
  }
417
473
  function registerWriteDescription(server) {
@@ -432,6 +488,12 @@ function registerWriteDescription(server) {
432
488
  "writing a list YouTube would ignore), and a Credits section read from what is on disk: the tool, " +
433
489
  "the music (an original compose_score track, an imported CC0 track with its source, or the built-in " +
434
490
  "bed), narration engine and voice, and any sound effect whose licence asks for a credit. " +
491
+ "For a lesson (Supernova mode) whose concept card (src/videos/<videoName>/concept.json) has a practiceQuestion " +
492
+ "and an answer, the description gains a Practice question section with the question and the line " +
493
+ "\"Drop your answer in the comments.\", and the answer goes only to output/<videoName>-ANSWER.md (a ready-to-paste " +
494
+ "pinned comment: the answer, plus a line of working from the card's optional answerWorking), returned as answerPath. " +
495
+ "The `disclaimers` string array in openvidstudio.config.json is appended to every lesson description, each line " +
496
+ "once, at the end. A project without these fields gets the same file as before. " +
435
497
  "finalize_video writes this file on every run, so a finished video always has one.",
436
498
  inputSchema: {
437
499
  projectRoot: zod_1.z.string().optional(),