coursecode 0.1.37 → 0.1.39
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/bin/cli.js +1 -0
- package/framework/js/dev/runtime-linter.js +17 -0
- package/framework/scripts/generate-narration.js +59 -198
- package/framework/scripts/narration-parser.js +204 -0
- package/lib/authoring-api.js +126 -0
- package/lib/build-linter.js +88 -0
- package/lib/mcp-prompts.js +55 -0
- package/lib/mcp-server.js +11 -1
- package/lib/narration.js +1 -0
- package/lib/preview-routes-api.js +41 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -141,6 +141,7 @@ program
|
|
|
141
141
|
.option('-f, --force', 'Regenerate all narration (ignore cache)')
|
|
142
142
|
.option('-s, --slide <id>', 'Generate narration for a specific slide only')
|
|
143
143
|
.option('--dry-run', 'Preview what would be generated')
|
|
144
|
+
.option('--rebuild-cache', 'Rebuild .narration-cache.json from existing audio (no TTS calls)')
|
|
144
145
|
.action(async (options) => {
|
|
145
146
|
const { narration } = await import('../lib/narration.js');
|
|
146
147
|
await narration(options);
|
|
@@ -205,6 +205,23 @@ export async function lintCourse(courseConfig) {
|
|
|
205
205
|
// Cleanup offscreen container
|
|
206
206
|
cleanupOffscreenContainer();
|
|
207
207
|
|
|
208
|
+
// --- 4. Filesystem-backed checks via preview server ---
|
|
209
|
+
// The browser can't read .narration-cache.json or hash slide source files,
|
|
210
|
+
// so the preview server exposes /__lint/narration to do that work and
|
|
211
|
+
// return any stale-narration warnings. Silently ignored if the endpoint
|
|
212
|
+
// is unavailable (e.g. running outside the preview server).
|
|
213
|
+
try {
|
|
214
|
+
const resp = await fetch('/__lint/narration', { cache: 'no-store' });
|
|
215
|
+
if (resp.ok) {
|
|
216
|
+
const data = await resp.json();
|
|
217
|
+
if (Array.isArray(data.warnings)) {
|
|
218
|
+
warnings.push(...data.warnings);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Endpoint absent (e.g. SCORM package preview) — skip silently.
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
// Display warnings individually so each appears as a separate entry in the debug panel
|
|
209
226
|
if (warnings.length > 0) {
|
|
210
227
|
for (const w of warnings) {
|
|
@@ -32,9 +32,15 @@
|
|
|
32
32
|
|
|
33
33
|
import fs from 'fs';
|
|
34
34
|
import path from 'path';
|
|
35
|
-
import crypto from 'crypto';
|
|
36
35
|
import { fileURLToPath } from 'url';
|
|
37
36
|
import { getActiveProvider, printProviderHelp, listProviders as _listProviders } from './tts-providers/index.js';
|
|
37
|
+
import {
|
|
38
|
+
parseSlideNarration as parseSlideNarrationShared,
|
|
39
|
+
hashContent,
|
|
40
|
+
loadNarrationCache,
|
|
41
|
+
narrationCacheKey,
|
|
42
|
+
VOICE_SETTING_KEYS as _SHARED_VOICE_KEYS
|
|
43
|
+
} from './narration-parser.js';
|
|
38
44
|
|
|
39
45
|
const __filename = fileURLToPath(import.meta.url);
|
|
40
46
|
const __dirname = path.dirname(__filename);
|
|
@@ -48,8 +54,8 @@ const AUDIO_DIR = path.join(ASSETS_DIR, 'audio');
|
|
|
48
54
|
const SLIDES_DIR = path.join(COURSE_DIR, 'slides');
|
|
49
55
|
const CACHE_FILE = path.join(SCORM_TEMPLATE_DIR, '.narration-cache.json');
|
|
50
56
|
|
|
51
|
-
// Reserved keys for voice settings (not narration content)
|
|
52
|
-
const VOICE_SETTING_KEYS =
|
|
57
|
+
// Reserved keys for voice settings (not narration content) — re-exported from shared module
|
|
58
|
+
const VOICE_SETTING_KEYS = _SHARED_VOICE_KEYS;
|
|
53
59
|
|
|
54
60
|
// Parse command line arguments
|
|
55
61
|
const args = process.argv.slice(2);
|
|
@@ -59,6 +65,10 @@ const VERBOSE = args.includes('--verbose') || args.includes('-v');
|
|
|
59
65
|
const SLIDE_FILTER = args.includes('--slide') ? args[args.indexOf('--slide') + 1] : null;
|
|
60
66
|
const SHOW_PROVIDERS = args.includes('--providers') || args.includes('--provider');
|
|
61
67
|
const SHOW_HELP = args.includes('--help') || args.includes('-h');
|
|
68
|
+
// Rebuild .narration-cache.json from current slide text without calling any
|
|
69
|
+
// TTS provider. Useful when audio is committed but the cache is missing
|
|
70
|
+
// (e.g. after a fresh clone or migrating a project).
|
|
71
|
+
const REBUILD_CACHE = args.includes('--rebuild-cache');
|
|
62
72
|
|
|
63
73
|
/**
|
|
64
74
|
* Load environment variables from .env file
|
|
@@ -202,195 +212,23 @@ function categorizeAndFilterSources(sources) {
|
|
|
202
212
|
return result;
|
|
203
213
|
}
|
|
204
214
|
|
|
205
|
-
/**
|
|
206
|
-
* Load the narration cache
|
|
207
|
-
*/
|
|
215
|
+
/** Load the narration cache (delegates to shared module) */
|
|
208
216
|
function loadCache() {
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
212
|
-
} catch {
|
|
213
|
-
return {};
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return {};
|
|
217
|
+
return loadNarrationCache(CACHE_FILE);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
/**
|
|
220
|
-
* Save the narration cache
|
|
221
|
-
*/
|
|
220
|
+
/** Save the narration cache */
|
|
222
221
|
function saveCache(cache) {
|
|
223
222
|
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
224
223
|
}
|
|
225
224
|
|
|
226
|
-
/**
|
|
227
|
-
* Calculate MD5 hash of content
|
|
228
|
-
*/
|
|
229
|
-
function hashContent(content) {
|
|
230
|
-
return crypto.createHash('md5').update(content).digest('hex');
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Extract narration export from a slide file using static analysis.
|
|
237
|
-
* This avoids importing the module (which would fail due to browser-only dependencies).
|
|
238
|
-
*
|
|
239
|
-
* Supports:
|
|
240
|
-
* Simple string: export const narration = `text`;
|
|
241
|
-
* Object with text: export const narration = { text: `...`, voice_id: '...' };
|
|
242
|
-
* Multi-key object: export const narration = { slide: `...`, 'modal-id': `...`, 'tab-id': `...` };
|
|
243
|
-
*
|
|
244
|
-
* Returns array of narration items with key, text, settings, outputPath
|
|
245
|
-
*/
|
|
225
|
+
/** Wrapper that injects this script's AUDIO_DIR into the shared parser. */
|
|
246
226
|
function parseSlideNarration(filePath, baseName) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// Remove block comments (/* ... */) to avoid matching examples in JSDoc
|
|
250
|
-
content = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
251
|
-
|
|
252
|
-
// Remove single-line comments (// ...)
|
|
253
|
-
content = content.replace(/\/\/.*$/gm, '');
|
|
254
|
-
|
|
255
|
-
// Try to match the full narration export - look for the complete object/value
|
|
256
|
-
// This regex matches from 'export const narration =' until we hit another export, async, function, etc.
|
|
257
|
-
const exportMatch = content.match(/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/);
|
|
258
|
-
|
|
259
|
-
if (!exportMatch) {
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const exportValue = exportMatch[1].trim();
|
|
264
|
-
|
|
265
|
-
// Case 1: Simple template literal - export const narration = `text`;
|
|
266
|
-
if (exportValue.startsWith('`') && exportValue.endsWith('`')) {
|
|
267
|
-
const text = exportValue.slice(1, -1).trim();
|
|
268
|
-
return [{
|
|
269
|
-
key: 'slide',
|
|
270
|
-
text,
|
|
271
|
-
settings: {},
|
|
272
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
273
|
-
}];
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Case 2: Simple quoted string - export const narration = "text" or 'text'
|
|
277
|
-
if ((exportValue.startsWith('"') && exportValue.endsWith('"')) ||
|
|
278
|
-
(exportValue.startsWith("'") && exportValue.endsWith("'"))) {
|
|
279
|
-
const text = exportValue.slice(1, -1).trim();
|
|
280
|
-
return [{
|
|
281
|
-
key: 'slide',
|
|
282
|
-
text,
|
|
283
|
-
settings: {},
|
|
284
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
285
|
-
}];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Case 3: Object - parse keys and values
|
|
289
|
-
if (exportValue.startsWith('{')) {
|
|
290
|
-
return parseNarrationObject(exportValue, baseName);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return null;
|
|
227
|
+
return parseSlideNarrationShared(filePath, baseName, AUDIO_DIR);
|
|
294
228
|
}
|
|
295
229
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* Handles: { slide: `...`, 'modal-id': `...`, text: `...`, voice_id: '...' }
|
|
299
|
-
*/
|
|
300
|
-
function parseNarrationObject(objectStr, baseName) {
|
|
301
|
-
const results = [];
|
|
302
|
-
let globalSettings = {};
|
|
303
|
-
|
|
304
|
-
// Extract voice settings (support both ElevenLabs and common formats)
|
|
305
|
-
const settingPatterns = [
|
|
306
|
-
{ key: 'voice_id', regex: /voice_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
307
|
-
{ key: 'voice', regex: /voice\s*:\s*['"]([^'"]+)['"]/ },
|
|
308
|
-
{ key: 'model_id', regex: /model_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
309
|
-
{ key: 'model', regex: /model\s*:\s*['"]([^'"]+)['"]/ },
|
|
310
|
-
{ key: 'stability', regex: /stability\s*:\s*([\d.]+)/ },
|
|
311
|
-
{ key: 'similarity_boost', regex: /similarity_boost\s*:\s*([\d.]+)/ },
|
|
312
|
-
{ key: 'speed', regex: /speed\s*:\s*([\d.]+)/ },
|
|
313
|
-
{ key: 'rate', regex: /rate\s*:\s*['"]([^'"]+)['"]/ },
|
|
314
|
-
{ key: 'pitch', regex: /pitch\s*:\s*['"]([^'"]+)['"]/ },
|
|
315
|
-
{ key: 'style', regex: /style\s*:\s*['"]([^'"]+)['"]/ }
|
|
316
|
-
];
|
|
317
|
-
|
|
318
|
-
for (const { key, regex } of settingPatterns) {
|
|
319
|
-
const match = objectStr.match(regex);
|
|
320
|
-
if (match) globalSettings[key] = match[1];
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Check for old format: { text: `...` } (single narration with settings)
|
|
324
|
-
const singleTextMatch = objectStr.match(/^\s*\{\s*text\s*:\s*`([\s\S]*?)`/);
|
|
325
|
-
if (singleTextMatch && !objectStr.match(/slide\s*:/)) {
|
|
326
|
-
return [{
|
|
327
|
-
key: 'slide',
|
|
328
|
-
text: singleTextMatch[1].trim(),
|
|
329
|
-
settings: globalSettings,
|
|
330
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
331
|
-
}];
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Multi-key format: { slide: `...`, 'key': `...` }
|
|
335
|
-
// Match patterns like: slide: `text` or 'modal-id': `text` or "tab-id": `text`
|
|
336
|
-
const keyValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*`([\s\S]*?)`/g;
|
|
337
|
-
let match;
|
|
338
|
-
|
|
339
|
-
while ((match = keyValueRegex.exec(objectStr)) !== null) {
|
|
340
|
-
const key = match[2] || match[3]; // Quoted key or unquoted key
|
|
341
|
-
const text = match[4].trim();
|
|
342
|
-
|
|
343
|
-
// Skip voice setting keys
|
|
344
|
-
if (VOICE_SETTING_KEYS.includes(key)) continue;
|
|
345
|
-
|
|
346
|
-
// Skip 'text' key in old format (already handled above)
|
|
347
|
-
if (key === 'text') continue;
|
|
348
|
-
|
|
349
|
-
// Determine output filename
|
|
350
|
-
let outputPath;
|
|
351
|
-
if (key === 'slide') {
|
|
352
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
|
|
353
|
-
} else {
|
|
354
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
results.push({
|
|
358
|
-
key,
|
|
359
|
-
text,
|
|
360
|
-
settings: { ...globalSettings },
|
|
361
|
-
outputPath
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Also match quoted string values: 'key': "text" or 'key': 'text'
|
|
366
|
-
const quotedValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*(['"])([\s\S]*?)\4/g;
|
|
367
|
-
while ((match = quotedValueRegex.exec(objectStr)) !== null) {
|
|
368
|
-
const key = match[2] || match[3];
|
|
369
|
-
const text = match[5].trim();
|
|
370
|
-
|
|
371
|
-
if (VOICE_SETTING_KEYS.includes(key)) continue;
|
|
372
|
-
if (key === 'text') continue;
|
|
373
|
-
|
|
374
|
-
// Check if we already have this key (from template literal match)
|
|
375
|
-
if (results.some(r => r.key === key)) continue;
|
|
376
|
-
|
|
377
|
-
let outputPath;
|
|
378
|
-
if (key === 'slide') {
|
|
379
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
|
|
380
|
-
} else {
|
|
381
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
results.push({
|
|
385
|
-
key,
|
|
386
|
-
text,
|
|
387
|
-
settings: { ...globalSettings },
|
|
388
|
-
outputPath
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
return results.length > 0 ? results : null;
|
|
393
|
-
}
|
|
230
|
+
// Removed: original inline parseSlideNarration / parseNarrationObject implementations
|
|
231
|
+
// now live in framework/scripts/narration-parser.js so the build linter can reuse them.
|
|
394
232
|
|
|
395
233
|
/**
|
|
396
234
|
* Main execution
|
|
@@ -410,11 +248,12 @@ async function main() {
|
|
|
410
248
|
|
|
411
249
|
Usage:
|
|
412
250
|
npm run narration Generate all changed narration
|
|
413
|
-
npm run narration -- --force
|
|
414
|
-
npm run narration -- --dry-run
|
|
415
|
-
npm run narration -- --slide <id>
|
|
416
|
-
npm run narration -- --
|
|
417
|
-
npm run narration -- --
|
|
251
|
+
npm run narration -- --force Regenerate all (ignore cache)
|
|
252
|
+
npm run narration -- --dry-run Preview without generating
|
|
253
|
+
npm run narration -- --slide <id> Generate specific slide only
|
|
254
|
+
npm run narration -- --rebuild-cache Rebuild cache from existing audio (no TTS calls)
|
|
255
|
+
npm run narration -- --providers List available TTS providers
|
|
256
|
+
npm run narration -- --verbose Show detailed output
|
|
418
257
|
|
|
419
258
|
Provider Selection:
|
|
420
259
|
Set TTS_PROVIDER env var to: elevenlabs, openai, or azure
|
|
@@ -432,17 +271,22 @@ Examples:
|
|
|
432
271
|
// Load environment variables
|
|
433
272
|
loadEnv();
|
|
434
273
|
|
|
435
|
-
// Initialize TTS provider
|
|
274
|
+
// Initialize TTS provider — skipped in --rebuild-cache mode since we
|
|
275
|
+
// never call the provider when only refreshing the cache file.
|
|
436
276
|
let provider;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
277
|
+
if (REBUILD_CACHE) {
|
|
278
|
+
console.log('🔁 Rebuild-cache mode: hashing existing audio, no TTS calls.\n');
|
|
279
|
+
} else {
|
|
280
|
+
try {
|
|
281
|
+
provider = getActiveProvider();
|
|
282
|
+
provider.validateConfig();
|
|
283
|
+
const defaultVoice = provider.getDefaultVoiceId();
|
|
284
|
+
console.log(`🔊 Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error(`❌ Provider error: ${error.message}\n`);
|
|
287
|
+
printProviderHelp();
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
446
290
|
}
|
|
447
291
|
|
|
448
292
|
// Load course config
|
|
@@ -541,7 +385,24 @@ Examples:
|
|
|
541
385
|
const cacheKey = key === 'slide' ? source.src : `${source.src}#${key}`;
|
|
542
386
|
const cachedHash = cache[cacheKey];
|
|
543
387
|
const outputExists = fs.existsSync(outputPath);
|
|
544
|
-
|
|
388
|
+
|
|
389
|
+
// --rebuild-cache: hash text+settings for existing audio and move on.
|
|
390
|
+
// Never call TTS, never write or delete audio files.
|
|
391
|
+
if (REBUILD_CACHE) {
|
|
392
|
+
if (outputExists) {
|
|
393
|
+
newCache[cacheKey] = contentHash;
|
|
394
|
+
skipped++;
|
|
395
|
+
if (VERBOSE) {
|
|
396
|
+
const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
|
|
397
|
+
console.log(` ✅ ${label}: cached (${path.relative(ROOT_DIR, outputPath)})`);
|
|
398
|
+
}
|
|
399
|
+
} else if (VERBOSE) {
|
|
400
|
+
const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
|
|
401
|
+
console.log(` ⏭️ ${label}: no audio yet, skipping`);
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
545
406
|
if (cachedHash === contentHash && outputExists && !FORCE_REGENERATE) {
|
|
546
407
|
if (VERBOSE) {
|
|
547
408
|
const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narration Parser - shared static-analysis utilities for narration sources.
|
|
3
|
+
*
|
|
4
|
+
* Used by both:
|
|
5
|
+
* - framework/scripts/generate-narration.js (audio generation + cache writes)
|
|
6
|
+
* - lib/build-linter.js (stale narration detection)
|
|
7
|
+
*
|
|
8
|
+
* Pure: no TTS calls, no audio I/O. Safe to import in any Node context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
// Reserved keys for voice settings (not narration content)
|
|
16
|
+
export const VOICE_SETTING_KEYS = [
|
|
17
|
+
'voice_id', 'model_id', 'stability', 'similarity_boost',
|
|
18
|
+
'voice', 'model', 'speed', 'rate', 'pitch', 'style'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* MD5 hash of arbitrary string content. Matches the generator's algorithm so
|
|
23
|
+
* the linter can recompute hashes and compare against `.narration-cache.json`.
|
|
24
|
+
*/
|
|
25
|
+
export function hashContent(content) {
|
|
26
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compute the cache key for a narration item, matching the generator's format.
|
|
31
|
+
* `slide` key uses the bare source path; other keys are suffixed with `#key`.
|
|
32
|
+
*/
|
|
33
|
+
export function narrationCacheKey(sourceSrc, itemKey) {
|
|
34
|
+
return itemKey === 'slide' ? sourceSrc : `${sourceSrc}#${itemKey}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the audio output path for a narration item.
|
|
39
|
+
*/
|
|
40
|
+
export function narrationAudioPath(audioDir, baseName, itemKey) {
|
|
41
|
+
return itemKey === 'slide'
|
|
42
|
+
? path.join(audioDir, `${baseName}.mp3`)
|
|
43
|
+
: path.join(audioDir, `${baseName}--${itemKey}.mp3`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the narration cache file. Returns {} if missing or unreadable.
|
|
48
|
+
*/
|
|
49
|
+
export function loadNarrationCache(cacheFile) {
|
|
50
|
+
if (!fs.existsSync(cacheFile)) return {};
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract narration export from a slide file using static analysis.
|
|
60
|
+
* This avoids importing the module (which would fail due to browser-only deps).
|
|
61
|
+
*
|
|
62
|
+
* Returns null if no narration export is present, otherwise an array of items:
|
|
63
|
+
* [{ key, text, settings, outputPath }]
|
|
64
|
+
*
|
|
65
|
+
* @param {string} filePath - Absolute path to slide JS file.
|
|
66
|
+
* @param {string} baseName - File stem used to derive output filenames.
|
|
67
|
+
* @param {string} audioDir - Directory where .mp3 files are written.
|
|
68
|
+
*/
|
|
69
|
+
export function parseSlideNarration(filePath, baseName, audioDir) {
|
|
70
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
|
|
72
|
+
// Remove block comments (/* ... */) to avoid matching JSDoc examples
|
|
73
|
+
content = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
74
|
+
// Remove single-line comments
|
|
75
|
+
content = content.replace(/\/\/.*$/gm, '');
|
|
76
|
+
|
|
77
|
+
// Match the full narration export up to the next top-level statement
|
|
78
|
+
const exportMatch = content.match(
|
|
79
|
+
/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!exportMatch) return null;
|
|
83
|
+
|
|
84
|
+
const exportValue = exportMatch[1].trim();
|
|
85
|
+
|
|
86
|
+
// Case 1: template literal — export const narration = `text`;
|
|
87
|
+
if (exportValue.startsWith('`') && exportValue.endsWith('`')) {
|
|
88
|
+
return [{
|
|
89
|
+
key: 'slide',
|
|
90
|
+
text: exportValue.slice(1, -1).trim(),
|
|
91
|
+
settings: {},
|
|
92
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Case 2: quoted string
|
|
97
|
+
if ((exportValue.startsWith('"') && exportValue.endsWith('"')) ||
|
|
98
|
+
(exportValue.startsWith("'") && exportValue.endsWith("'"))) {
|
|
99
|
+
return [{
|
|
100
|
+
key: 'slide',
|
|
101
|
+
text: exportValue.slice(1, -1).trim(),
|
|
102
|
+
settings: {},
|
|
103
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Case 3: object — multi-key or { text, ...settings }
|
|
108
|
+
if (exportValue.startsWith('{')) {
|
|
109
|
+
return parseNarrationObject(exportValue, baseName, audioDir);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a narration object literal with multiple keys and/or voice settings.
|
|
117
|
+
* Handles: { slide: `...`, 'modal-id': `...`, voice_id: '...' }
|
|
118
|
+
* and { text: `...`, voice_id: '...' }
|
|
119
|
+
*/
|
|
120
|
+
export function parseNarrationObject(objectStr, baseName, audioDir) {
|
|
121
|
+
const results = [];
|
|
122
|
+
const globalSettings = {};
|
|
123
|
+
|
|
124
|
+
const settingPatterns = [
|
|
125
|
+
{ key: 'voice_id', regex: /voice_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
126
|
+
{ key: 'voice', regex: /voice\s*:\s*['"]([^'"]+)['"]/ },
|
|
127
|
+
{ key: 'model_id', regex: /model_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
128
|
+
{ key: 'model', regex: /model\s*:\s*['"]([^'"]+)['"]/ },
|
|
129
|
+
{ key: 'stability', regex: /stability\s*:\s*([\d.]+)/ },
|
|
130
|
+
{ key: 'similarity_boost', regex: /similarity_boost\s*:\s*([\d.]+)/ },
|
|
131
|
+
{ key: 'speed', regex: /speed\s*:\s*([\d.]+)/ },
|
|
132
|
+
{ key: 'rate', regex: /rate\s*:\s*['"]([^'"]+)['"]/ },
|
|
133
|
+
{ key: 'pitch', regex: /pitch\s*:\s*['"]([^'"]+)['"]/ },
|
|
134
|
+
{ key: 'style', regex: /style\s*:\s*['"]([^'"]+)['"]/ }
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const { key, regex } of settingPatterns) {
|
|
138
|
+
const match = objectStr.match(regex);
|
|
139
|
+
if (match) globalSettings[key] = match[1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Old format: { text: `...` } single narration with settings
|
|
143
|
+
const singleTextMatch = objectStr.match(/^\s*\{\s*text\s*:\s*`([\s\S]*?)`/);
|
|
144
|
+
if (singleTextMatch && !objectStr.match(/slide\s*:/)) {
|
|
145
|
+
return [{
|
|
146
|
+
key: 'slide',
|
|
147
|
+
text: singleTextMatch[1].trim(),
|
|
148
|
+
settings: globalSettings,
|
|
149
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
150
|
+
}];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Multi-key with template literals
|
|
154
|
+
const keyValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*`([\s\S]*?)`/g;
|
|
155
|
+
let match;
|
|
156
|
+
while ((match = keyValueRegex.exec(objectStr)) !== null) {
|
|
157
|
+
const key = match[2] || match[3];
|
|
158
|
+
const text = match[4].trim();
|
|
159
|
+
if (VOICE_SETTING_KEYS.includes(key) || key === 'text') continue;
|
|
160
|
+
results.push({
|
|
161
|
+
key,
|
|
162
|
+
text,
|
|
163
|
+
settings: { ...globalSettings },
|
|
164
|
+
outputPath: narrationAudioPath(audioDir, baseName, key)
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Multi-key with quoted string values
|
|
169
|
+
const quotedValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*(['"])([\s\S]*?)\4/g;
|
|
170
|
+
while ((match = quotedValueRegex.exec(objectStr)) !== null) {
|
|
171
|
+
const key = match[2] || match[3];
|
|
172
|
+
const text = match[5].trim();
|
|
173
|
+
if (VOICE_SETTING_KEYS.includes(key) || key === 'text') continue;
|
|
174
|
+
if (results.some(r => r.key === key)) continue;
|
|
175
|
+
results.push({
|
|
176
|
+
key,
|
|
177
|
+
text,
|
|
178
|
+
settings: { ...globalSettings },
|
|
179
|
+
outputPath: narrationAudioPath(audioDir, baseName, key)
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return results.length > 0 ? results : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify the freshness of a single narration item. Pure function — caller
|
|
188
|
+
* supplies the parsed item, the cached hash, and the audio file existence flag.
|
|
189
|
+
*
|
|
190
|
+
* Returns one of:
|
|
191
|
+
* 'ok' - audio exists AND cached hash matches current text+settings
|
|
192
|
+
* 'stale' - audio exists AND cached hash differs (or no cache entry)
|
|
193
|
+
* 'missing' - text defined but audio file does not exist
|
|
194
|
+
* 'unknown' - audio exists but no cache file at all (can't determine; caller
|
|
195
|
+
* typically suppresses these to avoid noise on fresh clones)
|
|
196
|
+
*/
|
|
197
|
+
export function classifyNarrationFreshness({ item, cachedHash, audioExists, cacheLoaded }) {
|
|
198
|
+
const currentHash = hashContent(item.text + JSON.stringify(item.settings || {}));
|
|
199
|
+
|
|
200
|
+
if (!audioExists) return 'missing';
|
|
201
|
+
if (!cacheLoaded) return 'unknown';
|
|
202
|
+
if (cachedHash === currentHash) return 'ok';
|
|
203
|
+
return 'stale';
|
|
204
|
+
}
|
package/lib/authoring-api.js
CHANGED
|
@@ -1110,3 +1110,129 @@ export async function buildCourse(options = {}) {
|
|
|
1110
1110
|
});
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Run the narration generator script and return a structured result.
|
|
1115
|
+
*
|
|
1116
|
+
* Spawns `framework/scripts/generate-narration.js` from the course root so it
|
|
1117
|
+
* picks up the same .env, course-config, and slide files a manual run would.
|
|
1118
|
+
*
|
|
1119
|
+
* @param {object} options
|
|
1120
|
+
* @param {boolean} [options.dryRun=false] - Show what would be generated without calling TTS
|
|
1121
|
+
* @param {boolean} [options.force=false] - Regenerate everything, ignoring the cache
|
|
1122
|
+
* @param {string} [options.slide] - Limit to a single slide ID
|
|
1123
|
+
* @param {boolean} [options.rebuildCache=false] - Rebuild .narration-cache.json from existing audio without calling TTS
|
|
1124
|
+
* @returns {Promise<{success, dryRun, summary, generated, skipped, errors, warnings, output, duration}>}
|
|
1125
|
+
*/
|
|
1126
|
+
export async function generateNarration(options = {}) {
|
|
1127
|
+
const { dryRun = false, force = false, slide, rebuildCache = false } = options;
|
|
1128
|
+
const startTime = Date.now();
|
|
1129
|
+
|
|
1130
|
+
let courseRoot;
|
|
1131
|
+
try {
|
|
1132
|
+
courseRoot = getCourseRoot();
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
return {
|
|
1135
|
+
success: false,
|
|
1136
|
+
error: error.message,
|
|
1137
|
+
errors: [error.message],
|
|
1138
|
+
warnings: [],
|
|
1139
|
+
output: '',
|
|
1140
|
+
duration: '0s'
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const scriptPath = path.join(courseRoot, 'framework', 'scripts', 'generate-narration.js');
|
|
1145
|
+
if (!fs.existsSync(scriptPath)) {
|
|
1146
|
+
const msg = `Narration script not found at ${scriptPath}. Run \`coursecode upgrade\` to install framework scripts.`;
|
|
1147
|
+
return {
|
|
1148
|
+
success: false,
|
|
1149
|
+
error: msg,
|
|
1150
|
+
errors: [msg],
|
|
1151
|
+
warnings: [],
|
|
1152
|
+
output: '',
|
|
1153
|
+
duration: '0s'
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const args = [scriptPath];
|
|
1158
|
+
if (force) args.push('--force');
|
|
1159
|
+
if (dryRun) args.push('--dry-run');
|
|
1160
|
+
if (slide) args.push('--slide', slide);
|
|
1161
|
+
if (rebuildCache) args.push('--rebuild-cache');
|
|
1162
|
+
|
|
1163
|
+
return new Promise((resolve) => {
|
|
1164
|
+
const child = spawn('node', args, {
|
|
1165
|
+
cwd: courseRoot,
|
|
1166
|
+
env: process.env,
|
|
1167
|
+
shell: false,
|
|
1168
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
let stdout = '';
|
|
1172
|
+
let stderr = '';
|
|
1173
|
+
|
|
1174
|
+
// Cap captured output to keep MCP responses manageable
|
|
1175
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
1176
|
+
child.stdout.on('data', (d) => {
|
|
1177
|
+
if (stdout.length < MAX_OUTPUT) stdout += d.toString();
|
|
1178
|
+
});
|
|
1179
|
+
child.stderr.on('data', (d) => {
|
|
1180
|
+
if (stderr.length < MAX_OUTPUT) stderr += d.toString();
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
child.on('close', (code) => {
|
|
1184
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1185
|
+
const output = stdout + (stderr ? `\n${stderr}` : '');
|
|
1186
|
+
|
|
1187
|
+
// Parse summary line: "✨ Complete: 3 generated, 5 unchanged, 1 errors"
|
|
1188
|
+
const summaryMatch = output.match(/Complete:\s*(.+?)(?:\n|$)/);
|
|
1189
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : null;
|
|
1190
|
+
|
|
1191
|
+
const numFromSummary = (label) => {
|
|
1192
|
+
if (!summary) return 0;
|
|
1193
|
+
const m = summary.match(new RegExp(`(\\d+)\\s+${label}`));
|
|
1194
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
const errors = [];
|
|
1198
|
+
const warnings = [];
|
|
1199
|
+
for (const line of output.split('\n')) {
|
|
1200
|
+
const trimmed = line.trim();
|
|
1201
|
+
if (!trimmed) continue;
|
|
1202
|
+
if (trimmed.startsWith('❌') || trimmed.startsWith('Error:')) errors.push(trimmed);
|
|
1203
|
+
else if (trimmed.startsWith('⚠️')) warnings.push(trimmed);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
resolve({
|
|
1207
|
+
success: code === 0,
|
|
1208
|
+
dryRun,
|
|
1209
|
+
summary,
|
|
1210
|
+
generated: numFromSummary('generated'),
|
|
1211
|
+
skipped: numFromSummary('unchanged'),
|
|
1212
|
+
noNarration: numFromSummary('no export'),
|
|
1213
|
+
errors,
|
|
1214
|
+
warnings,
|
|
1215
|
+
output: output.slice(-8000), // Tail for MCP response (full available in stdout)
|
|
1216
|
+
duration,
|
|
1217
|
+
message: code === 0
|
|
1218
|
+
? `Narration ${dryRun ? '(dry-run) ' : ''}complete in ${duration}${summary ? `: ${summary}` : ''}`
|
|
1219
|
+
: `Narration failed (exit ${code}). ${errors.length} error(s).`
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
child.on('error', (error) => {
|
|
1224
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1225
|
+
resolve({
|
|
1226
|
+
success: false,
|
|
1227
|
+
dryRun,
|
|
1228
|
+
error: error.message,
|
|
1229
|
+
errors: [error.message],
|
|
1230
|
+
warnings: [],
|
|
1231
|
+
output: '',
|
|
1232
|
+
duration,
|
|
1233
|
+
message: `Narration failed: ${error.message}`
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
package/lib/build-linter.js
CHANGED
|
@@ -14,6 +14,12 @@ import path from 'path';
|
|
|
14
14
|
import { parseSlideSource, extractAssessment } from './course-parser.js';
|
|
15
15
|
import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
16
16
|
import { getValidCssClasses, lintCssSelectors } from './css-index.js';
|
|
17
|
+
import {
|
|
18
|
+
parseSlideNarration,
|
|
19
|
+
loadNarrationCache,
|
|
20
|
+
narrationCacheKey,
|
|
21
|
+
classifyNarrationFreshness
|
|
22
|
+
} from '../framework/scripts/narration-parser.js';
|
|
17
23
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
18
24
|
import {
|
|
19
25
|
flattenStructure,
|
|
@@ -104,6 +110,12 @@ export async function lintCourse(courseConfig, coursePath) {
|
|
|
104
110
|
await validateSlide(slide, coursePath, objectiveIds, errors, warnings, interactionIdRegistry, validCssIndex);
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
// Narration freshness check (opt-out via courseConfig.lint.narrationFreshness === false)
|
|
114
|
+
const narrationFreshnessEnabled = courseConfig.lint?.narrationFreshness !== false;
|
|
115
|
+
if (narrationFreshnessEnabled) {
|
|
116
|
+
checkNarrationFreshness(slides, coursePath, warnings);
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
return { errors, warnings };
|
|
108
120
|
}
|
|
109
121
|
|
|
@@ -398,6 +410,82 @@ function collectJsFiles(dir, result) {
|
|
|
398
410
|
}
|
|
399
411
|
}
|
|
400
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Check that generated narration audio is in sync with the narration text in
|
|
415
|
+
* each slide. Emits warnings when:
|
|
416
|
+
* - The .mp3 exists but the cached hash differs from current text+settings (stale)
|
|
417
|
+
* - Narration text is defined but no .mp3 exists (missing)
|
|
418
|
+
*
|
|
419
|
+
* Silent when:
|
|
420
|
+
* - The slide has no narration export
|
|
421
|
+
* - No audio file exists AND no cache entry (treat as "narration not yet
|
|
422
|
+
* generated" — author may not have configured TTS yet)
|
|
423
|
+
* - The narration cache file is absent (can't determine staleness; avoids
|
|
424
|
+
* false positives on freshly cloned repos)
|
|
425
|
+
*
|
|
426
|
+
* Opt out via courseConfig.lint.narrationFreshness === false.
|
|
427
|
+
*
|
|
428
|
+
* @param {Array} slides - Flattened slide list.
|
|
429
|
+
* @param {string} coursePath - Path to the course directory (contains slides/).
|
|
430
|
+
* @param {string[]} warnings - Output array; warnings are pushed in place.
|
|
431
|
+
*/
|
|
432
|
+
export function checkNarrationFreshness(slides, coursePath, warnings) {
|
|
433
|
+
const slidesDir = path.join(coursePath, 'slides');
|
|
434
|
+
const audioDir = path.join(coursePath, 'assets', 'audio');
|
|
435
|
+
// Cache file lives at the project root, one level above coursePath.
|
|
436
|
+
const projectRoot = path.dirname(coursePath);
|
|
437
|
+
const cacheFile = path.join(projectRoot, '.narration-cache.json');
|
|
438
|
+
|
|
439
|
+
const cacheLoaded = fs.existsSync(cacheFile);
|
|
440
|
+
const cache = cacheLoaded ? loadNarrationCache(cacheFile) : {};
|
|
441
|
+
|
|
442
|
+
for (const slide of slides) {
|
|
443
|
+
if (!slide.component || !slide.component.startsWith('@slides/')) continue;
|
|
444
|
+
|
|
445
|
+
const slideFileName = slide.component.replace('@slides/', '');
|
|
446
|
+
const slideFilePath = path.join(slidesDir, slideFileName);
|
|
447
|
+
if (!fs.existsSync(slideFilePath)) continue;
|
|
448
|
+
|
|
449
|
+
const baseName = slideFileName.replace(/\.js$/, '');
|
|
450
|
+
|
|
451
|
+
let items;
|
|
452
|
+
try {
|
|
453
|
+
items = parseSlideNarration(slideFilePath, baseName, audioDir);
|
|
454
|
+
} catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!items) continue;
|
|
458
|
+
|
|
459
|
+
const sourceSrc = `@slides/${slideFileName}`;
|
|
460
|
+
|
|
461
|
+
for (const item of items) {
|
|
462
|
+
const cacheKey = narrationCacheKey(sourceSrc, item.key);
|
|
463
|
+
const audioExists = fs.existsSync(item.outputPath);
|
|
464
|
+
|
|
465
|
+
// Per spec: if no audio file exists, do NOT warn — author may not have
|
|
466
|
+
// generated narration yet (TTS provider may not even be configured).
|
|
467
|
+
if (!audioExists) continue;
|
|
468
|
+
|
|
469
|
+
const status = classifyNarrationFreshness({
|
|
470
|
+
item,
|
|
471
|
+
cachedHash: cache[cacheKey],
|
|
472
|
+
audioExists,
|
|
473
|
+
cacheLoaded
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (status === 'stale') {
|
|
477
|
+
const keyLabel = item.key === 'slide' ? '' : ` (key: "${item.key}")`;
|
|
478
|
+
const relAudio = path.relative(projectRoot, item.outputPath).replace(/\\/g, '/');
|
|
479
|
+
warnings.push(
|
|
480
|
+
`Slide "${slide.id}": narration audio is stale${keyLabel} \u2014 text changed since ${relAudio} was generated. Run \`coursecode narration\` to regenerate.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
// 'unknown' (cache file missing): silent — avoids noise on fresh clones.
|
|
484
|
+
// 'ok': silent.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
401
489
|
/**
|
|
402
490
|
* Lint framework JS source files for banned logging/error patterns.
|
|
403
491
|
* Prevents regression to pre-unified-logger patterns.
|
package/lib/mcp-prompts.js
CHANGED
|
@@ -407,6 +407,61 @@ Use AFTER making changes to validate the course.`,
|
|
|
407
407
|
idempotentHint: true
|
|
408
408
|
}
|
|
409
409
|
},
|
|
410
|
+
{
|
|
411
|
+
name: 'coursecode_narration',
|
|
412
|
+
description: `Generate (or dry-run) audio narration for slides via the configured TTS provider.
|
|
413
|
+
|
|
414
|
+
Reads \`export const narration = ...\` from each slide file, hashes text+voice settings, and only regenerates audio whose hash changed since the last run (cache: .narration-cache.json). MP3 files are written to course/assets/audio/.
|
|
415
|
+
|
|
416
|
+
Provider selection follows the same rules as the CLI:
|
|
417
|
+
TTS_PROVIDER env var, or auto-detect from API keys, default deepgram.
|
|
418
|
+
The .env file at the project root supplies API keys.
|
|
419
|
+
|
|
420
|
+
USE WHEN:
|
|
421
|
+
- The lint reports stale narration warnings ("narration audio is stale...").
|
|
422
|
+
- You added or modified narration text in a slide.
|
|
423
|
+
- You want to confirm what would be generated (use dryRun: true — no API calls, no audio written).
|
|
424
|
+
|
|
425
|
+
APPROVAL:
|
|
426
|
+
- Real generation (dryRun omitted or false) calls a TTS provider and writes MP3 files, so it requires explicit user approval before running.
|
|
427
|
+
- Use dryRun: true first when you only need to inspect what would be regenerated.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
- success, summary (e.g. "3 generated, 5 unchanged")
|
|
431
|
+
- generated, skipped, noNarration counts
|
|
432
|
+
- errors[], warnings[]
|
|
433
|
+
- output: tail of the generator's console output for debugging
|
|
434
|
+
- dryRun: echoes the input flag
|
|
435
|
+
|
|
436
|
+
NOT idempotent: real runs call paid TTS APIs and write files. Prefer dryRun first.`,
|
|
437
|
+
inputSchema: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
dryRun: {
|
|
441
|
+
type: 'boolean',
|
|
442
|
+
description: 'Show what would be generated without calling TTS or writing files (default: false).'
|
|
443
|
+
},
|
|
444
|
+
force: {
|
|
445
|
+
type: 'boolean',
|
|
446
|
+
description: 'Regenerate every narration item, ignoring the cache (default: false). Use sparingly — costs API credits.'
|
|
447
|
+
},
|
|
448
|
+
slide: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
description: 'Optional slide ID filter (e.g. "intro"). Only narration for this slide is processed.'
|
|
451
|
+
},
|
|
452
|
+
rebuildCache: {
|
|
453
|
+
type: 'boolean',
|
|
454
|
+
description: 'Rebuild .narration-cache.json from existing audio files without calling TTS (default: false). Use after a fresh clone or when the cache is missing but audio is committed. Free; no API calls.'
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
required: []
|
|
458
|
+
},
|
|
459
|
+
annotations: {
|
|
460
|
+
readOnlyHint: false,
|
|
461
|
+
idempotentHint: false,
|
|
462
|
+
destructiveHint: false
|
|
463
|
+
}
|
|
464
|
+
},
|
|
410
465
|
{
|
|
411
466
|
name: 'coursecode_icon_catalog',
|
|
412
467
|
description: `Get icon information.
|
package/lib/mcp-server.js
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
getInteractionCatalog,
|
|
29
29
|
getIconCatalog,
|
|
30
30
|
lintCourse,
|
|
31
|
-
buildCourse
|
|
31
|
+
buildCourse,
|
|
32
|
+
generateNarration
|
|
32
33
|
} from './authoring-api.js';
|
|
33
34
|
|
|
34
35
|
import headless from './headless-browser.js';
|
|
@@ -282,6 +283,15 @@ export async function startMcpServer(options = {}) {
|
|
|
282
283
|
result = await lintCourse();
|
|
283
284
|
break;
|
|
284
285
|
|
|
286
|
+
case 'coursecode_narration':
|
|
287
|
+
result = await generateNarration({
|
|
288
|
+
dryRun: args?.dryRun === true,
|
|
289
|
+
force: args?.force === true,
|
|
290
|
+
slide: args?.slide,
|
|
291
|
+
rebuildCache: args?.rebuildCache === true
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
|
|
285
295
|
|
|
286
296
|
|
|
287
297
|
default:
|
package/lib/narration.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function narration(options = {}) {
|
|
|
32
32
|
if (options.force) args.push('--force');
|
|
33
33
|
if (options.slide) args.push('--slide', options.slide);
|
|
34
34
|
if (options.dryRun) args.push('--dry-run');
|
|
35
|
+
if (options.rebuildCache) args.push('--rebuild-cache');
|
|
35
36
|
|
|
36
37
|
return new Promise((resolve, reject) => {
|
|
37
38
|
const child = spawn('node', args, {
|
|
@@ -67,6 +67,47 @@ export async function handleApiRoutes(ctx, req, res, url) {
|
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Narration freshness check — runtime linter fetches this since it can't
|
|
71
|
+
// read the filesystem directly. Returns warnings for stale .mp3 files
|
|
72
|
+
// (audio exists but cached hash differs from current text+settings).
|
|
73
|
+
// Silent when audio is absent or .narration-cache.json is missing.
|
|
74
|
+
if (url === '/__lint/narration') {
|
|
75
|
+
try {
|
|
76
|
+
const { checkNarrationFreshness } = await import('./build-linter.js');
|
|
77
|
+
const { flattenStructure } = await import('./validation-rules.js');
|
|
78
|
+
const configPath = path.join(paths.coursePath, 'course-config.js');
|
|
79
|
+
if (!fs.existsSync(configPath)) {
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
81
|
+
res.end(JSON.stringify({ warnings: [] }));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// Cache-bust import so edits to course-config.js are picked up
|
|
85
|
+
const configUrl = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
|
|
86
|
+
const configModule = await import(configUrl);
|
|
87
|
+
const courseConfig = configModule.courseConfig || configModule.default;
|
|
88
|
+
if (!courseConfig?.structure) {
|
|
89
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify({ warnings: [] }));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// Respect the same opt-out as the build linter
|
|
94
|
+
if (courseConfig.lint?.narrationFreshness === false) {
|
|
95
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
96
|
+
res.end(JSON.stringify({ warnings: [], disabled: true }));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const slides = flattenStructure(courseConfig.structure);
|
|
100
|
+
const warnings = [];
|
|
101
|
+
checkNarrationFreshness(slides, paths.coursePath, warnings);
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
|
|
103
|
+
res.end(JSON.stringify({ warnings }));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
106
|
+
res.end(JSON.stringify({ warnings: [], error: err.message }));
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
// Refresh course content HTML
|
|
71
112
|
if (url === '/__content') {
|
|
72
113
|
generateContentHtml({ coursePath: paths.coursePath, includeNarration: true })
|