@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.
- package/dist/canvasExplainer.d.ts +198 -0
- package/dist/canvasExplainer.js +661 -0
- package/dist/canvasValidate.d.ts +76 -0
- package/dist/canvasValidate.js +263 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +2 -0
- package/dist/install.d.ts +63 -0
- package/dist/install.js +225 -0
- package/dist/lessonArc.d.ts +68 -0
- package/dist/lessonArc.js +188 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/stdio.js +12 -4
- package/dist/tools/checkScript.d.ts +31 -1
- package/dist/tools/checkScript.js +183 -6
- package/dist/tools/fitBeats.d.ts +69 -0
- package/dist/tools/fitBeats.js +229 -0
- package/dist/tools/generateNarration.d.ts +19 -0
- package/dist/tools/generateNarration.js +48 -5
- package/dist/tools/index.js +6 -0
- package/dist/tools/initProject.js +6 -1
- package/dist/tools/preflight.js +20 -1
- package/dist/tools/renderCanvasExplainer.d.ts +2 -0
- package/dist/tools/renderCanvasExplainer.js +56 -0
- package/dist/tools/renderVideo.d.ts +3 -0
- package/dist/tools/renderVideo.js +13 -4
- package/dist/tools/validateBeats.d.ts +2 -0
- package/dist/tools/validateBeats.js +5 -5
- package/dist/tools/validateCanvasExplainer.d.ts +2 -0
- package/dist/tools/validateCanvasExplainer.js +35 -0
- package/dist/tools/validateScenes.d.ts +6 -0
- package/dist/tools/validateScenes.js +27 -6
- package/dist/tools/validateScenesRender.d.ts +8 -0
- package/dist/tools/validateScenesRender.js +31 -0
- package/dist/tools/writeDescription.d.ts +6 -0
- package/dist/tools/writeDescription.js +64 -2
- package/docs/API.md +291 -0
- package/docs/NARRATION.md +12 -5
- package/docs/PIPELINE.md +46 -0
- package/docs/SCRIPT.md +63 -3
- package/docs/SUPERNOVA.md +53 -15
- package/docs/TOOLS.md +4 -1
- package/package.json +4 -4
- package/templates/default/openvidstudio-core/package.json +9 -9
- package/templates/default/openvidstudio-stem/package.json +5 -5
- package/templates/default/openvidstudio-stem/src/ComplexityCurve.tsx +108 -0
- package/templates/default/openvidstudio-stem/src/Derivation.tsx +334 -0
- package/templates/default/openvidstudio-stem/src/Equation.tsx +3 -3
- package/templates/default/openvidstudio-stem/src/GraphViz.tsx +249 -0
- package/templates/default/openvidstudio-stem/src/LessonCaptions.tsx +135 -0
- package/templates/default/openvidstudio-stem/src/MatrixGrid.tsx +187 -0
- package/templates/default/openvidstudio-stem/src/Orbit.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/TreeViz.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/VectorField.tsx +231 -0
- package/templates/default/openvidstudio-stem/src/Wave.tsx +234 -0
- package/templates/default/openvidstudio-stem/src/index.ts +21 -0
- 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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
20
|
-
|
|
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,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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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(),
|