coursecode 0.1.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/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- package/template/vite.config.js +339 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/* eslint-disable no-console */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Narration Generator Script
|
|
7
|
+
*
|
|
8
|
+
* Generates audio narration from text sources via configurable TTS providers.
|
|
9
|
+
* Supports ElevenLabs, OpenAI, and Azure Cognitive Services.
|
|
10
|
+
*
|
|
11
|
+
* Narration source: `export const narration` in slide JS files
|
|
12
|
+
* - Simple: export const narration = `text`;
|
|
13
|
+
* - Multi-key: export const narration = { slide: `...`, 'modal-id': `...`, 'tab-id': `...` };
|
|
14
|
+
* - Generates: audio/intro.mp3, audio/intro--modal-id.mp3, audio/intro--tab-id.mp3
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* npm run narration # Generate all changed narration
|
|
18
|
+
* npm run narration -- --force # Regenerate all narration (ignore cache)
|
|
19
|
+
* npm run narration -- --dry-run # Show what would be generated
|
|
20
|
+
* npm run narration -- --providers # List available TTS providers
|
|
21
|
+
*
|
|
22
|
+
* Provider Selection (in priority order):
|
|
23
|
+
* 1. TTS_PROVIDER env var (explicit: elevenlabs, openai, azure)
|
|
24
|
+
* 2. Auto-detect based on available API keys
|
|
25
|
+
* 3. Default to deepgram
|
|
26
|
+
*
|
|
27
|
+
* Provider Setup:
|
|
28
|
+
* ElevenLabs: ELEVENLABS_API_KEY (optional: ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL_ID)
|
|
29
|
+
* OpenAI: OPENAI_API_KEY (optional: OPENAI_VOICE, OPENAI_MODEL)
|
|
30
|
+
* Azure: AZURE_SPEECH_KEY + AZURE_SPEECH_REGION (optional: AZURE_VOICE)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'fs';
|
|
34
|
+
import path from 'path';
|
|
35
|
+
import crypto from 'crypto';
|
|
36
|
+
import { fileURLToPath } from 'url';
|
|
37
|
+
import { getActiveProvider, printProviderHelp, listProviders as _listProviders } from './tts-providers/index.js';
|
|
38
|
+
|
|
39
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
40
|
+
const __dirname = path.dirname(__filename);
|
|
41
|
+
// __dirname = framework/scripts, go up two levels to reach scorm_template
|
|
42
|
+
const SCORM_TEMPLATE_DIR = path.resolve(__dirname, '../..');
|
|
43
|
+
const ROOT_DIR = path.resolve(SCORM_TEMPLATE_DIR, '..');
|
|
44
|
+
const COURSE_DIR = path.join(SCORM_TEMPLATE_DIR, 'course');
|
|
45
|
+
const ASSETS_DIR = path.join(COURSE_DIR, 'assets');
|
|
46
|
+
const AUDIO_DIR = path.join(ASSETS_DIR, 'audio');
|
|
47
|
+
|
|
48
|
+
const SLIDES_DIR = path.join(COURSE_DIR, 'slides');
|
|
49
|
+
const CACHE_FILE = path.join(SCORM_TEMPLATE_DIR, '.narration-cache.json');
|
|
50
|
+
|
|
51
|
+
// Reserved keys for voice settings (not narration content)
|
|
52
|
+
const VOICE_SETTING_KEYS = ['voice_id', 'model_id', 'stability', 'similarity_boost', 'voice', 'model', 'speed', 'rate', 'pitch', 'style'];
|
|
53
|
+
|
|
54
|
+
// Parse command line arguments
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
const FORCE_REGENERATE = args.includes('--force');
|
|
57
|
+
const DRY_RUN = args.includes('--dry-run');
|
|
58
|
+
const VERBOSE = args.includes('--verbose') || args.includes('-v');
|
|
59
|
+
const SLIDE_FILTER = args.includes('--slide') ? args[args.indexOf('--slide') + 1] : null;
|
|
60
|
+
const SHOW_PROVIDERS = args.includes('--providers') || args.includes('--provider');
|
|
61
|
+
const SHOW_HELP = args.includes('--help') || args.includes('-h');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load environment variables from .env file
|
|
65
|
+
* Searches in multiple locations: CWD, SCORM_TEMPLATE_DIR, ROOT_DIR
|
|
66
|
+
*/
|
|
67
|
+
function loadEnv() {
|
|
68
|
+
const searchPaths = [
|
|
69
|
+
path.join(process.cwd(), '.env'), // Current working directory (most common)
|
|
70
|
+
path.join(SCORM_TEMPLATE_DIR, '.env'), // Template directory
|
|
71
|
+
path.join(ROOT_DIR, '.env') // Root directory
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const envPath of searchPaths) {
|
|
75
|
+
if (fs.existsSync(envPath)) {
|
|
76
|
+
const envContent = fs.readFileSync(envPath, 'utf-8');
|
|
77
|
+
for (const line of envContent.split('\n')) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (trimmed && !trimmed.startsWith('#')) {
|
|
80
|
+
const [key, ...valueParts] = trimmed.split('=');
|
|
81
|
+
const value = valueParts.join('=').replace(/^["']|["']$/g, '');
|
|
82
|
+
process.env[key.trim()] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (VERBOSE) {
|
|
86
|
+
console.log(` Loaded .env from: ${envPath}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load and parse course-config.js to extract audio sources
|
|
95
|
+
*/
|
|
96
|
+
async function loadCourseConfig() {
|
|
97
|
+
const configPath = path.join(COURSE_DIR, 'course-config.js');
|
|
98
|
+
|
|
99
|
+
if (!fs.existsSync(configPath)) {
|
|
100
|
+
throw new Error(`Course config not found: ${configPath}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Dynamic import of the ES module
|
|
104
|
+
const configModule = await import(`file://${configPath}`);
|
|
105
|
+
return configModule.courseConfig;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Recursively find all audio sources in the course structure
|
|
110
|
+
*/
|
|
111
|
+
function findAudioSources(structure, sources = []) {
|
|
112
|
+
for (const item of structure) {
|
|
113
|
+
// Check slide-level audio
|
|
114
|
+
if (item.audio?.src) {
|
|
115
|
+
sources.push({
|
|
116
|
+
slideId: item.id,
|
|
117
|
+
src: item.audio.src,
|
|
118
|
+
component: item.component,
|
|
119
|
+
type: 'slide'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Recurse into sections
|
|
124
|
+
if (item.children) {
|
|
125
|
+
findAudioSources(item.children, sources);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return sources;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scan all slide files for component-level narration exports (modal/tab audio).
|
|
133
|
+
* These are narration exports with multi-key format that are NOT referenced in course config.
|
|
134
|
+
*/
|
|
135
|
+
function findComponentNarrationSources() {
|
|
136
|
+
const sources = [];
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(SLIDES_DIR)) {
|
|
139
|
+
return sources;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const slideFiles = fs.readdirSync(SLIDES_DIR).filter(f => f.endsWith('.js'));
|
|
143
|
+
|
|
144
|
+
for (const slideFile of slideFiles) {
|
|
145
|
+
const filePath = path.join(SLIDES_DIR, slideFile);
|
|
146
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
147
|
+
|
|
148
|
+
// Quick check: does the file have a narration export?
|
|
149
|
+
if (!content.includes('export const narration')) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Remove block comments to avoid matching examples in JSDoc
|
|
154
|
+
content = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
155
|
+
|
|
156
|
+
// Check for narration export
|
|
157
|
+
const exportMatch = content.match(/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/);
|
|
158
|
+
|
|
159
|
+
if (exportMatch) {
|
|
160
|
+
const baseName = slideFile.replace('.js', '');
|
|
161
|
+
sources.push({
|
|
162
|
+
slideId: baseName,
|
|
163
|
+
src: `@slides/${slideFile}`,
|
|
164
|
+
component: `@slides/${slideFile}`,
|
|
165
|
+
type: 'component',
|
|
166
|
+
sourceType: 'slide',
|
|
167
|
+
sourcePath: filePath,
|
|
168
|
+
baseName
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return sources;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Determine source type and filter to only generatable sources
|
|
178
|
+
*
|
|
179
|
+
* Source types:
|
|
180
|
+
* @slides/file.js ā course/slides/file.js ā course/assets/audio/file.mp3 (+ keyed variants)
|
|
181
|
+
*/
|
|
182
|
+
function categorizeAndFilterSources(sources) {
|
|
183
|
+
const result = [];
|
|
184
|
+
|
|
185
|
+
for (const source of sources) {
|
|
186
|
+
const src = source.src;
|
|
187
|
+
|
|
188
|
+
// Slide file reference (@slides/...)
|
|
189
|
+
if (src.startsWith('@slides/') && src.endsWith('.js')) {
|
|
190
|
+
const slideFile = src.replace('@slides/', '');
|
|
191
|
+
const baseName = slideFile.replace('.js', '');
|
|
192
|
+
result.push({
|
|
193
|
+
...source,
|
|
194
|
+
sourceType: 'slide',
|
|
195
|
+
sourcePath: path.join(SLIDES_DIR, slideFile),
|
|
196
|
+
baseName
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Skip other sources (direct .mp3 files, URLs, etc.)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Load the narration cache
|
|
207
|
+
*/
|
|
208
|
+
function loadCache() {
|
|
209
|
+
if (fs.existsSync(CACHE_FILE)) {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
212
|
+
} catch {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Save the narration cache
|
|
221
|
+
*/
|
|
222
|
+
function saveCache(cache) {
|
|
223
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
224
|
+
}
|
|
225
|
+
|
|
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
|
+
*/
|
|
246
|
+
function parseSlideNarration(filePath, baseName) {
|
|
247
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
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;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Parse a narration object with multiple keys
|
|
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
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Main execution
|
|
397
|
+
*/
|
|
398
|
+
async function main() {
|
|
399
|
+
// Handle --providers flag
|
|
400
|
+
if (SHOW_PROVIDERS) {
|
|
401
|
+
loadEnv();
|
|
402
|
+
printProviderHelp(VERBOSE);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Handle --help flag
|
|
407
|
+
if (SHOW_HELP) {
|
|
408
|
+
console.log(`
|
|
409
|
+
šļø Narration Generator
|
|
410
|
+
|
|
411
|
+
Usage:
|
|
412
|
+
npm run narration Generate all changed narration
|
|
413
|
+
npm run narration -- --force Regenerate all (ignore cache)
|
|
414
|
+
npm run narration -- --dry-run Preview without generating
|
|
415
|
+
npm run narration -- --slide <id> Generate specific slide only
|
|
416
|
+
npm run narration -- --providers List available TTS providers
|
|
417
|
+
npm run narration -- --verbose Show detailed output
|
|
418
|
+
|
|
419
|
+
Provider Selection:
|
|
420
|
+
Set TTS_PROVIDER env var to: elevenlabs, openai, or azure
|
|
421
|
+
Or configure API keys and provider will be auto-detected.
|
|
422
|
+
|
|
423
|
+
Examples:
|
|
424
|
+
TTS_PROVIDER=openai npm run narration
|
|
425
|
+
OPENAI_API_KEY=sk-xxx npm run narration
|
|
426
|
+
`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
console.log('šļø Narration Generator\n');
|
|
431
|
+
|
|
432
|
+
// Load environment variables
|
|
433
|
+
loadEnv();
|
|
434
|
+
|
|
435
|
+
// Initialize TTS provider
|
|
436
|
+
let provider;
|
|
437
|
+
try {
|
|
438
|
+
provider = getActiveProvider();
|
|
439
|
+
provider.validateConfig();
|
|
440
|
+
const defaultVoice = provider.getDefaultVoiceId();
|
|
441
|
+
console.log(`š Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error(`ā Provider error: ${error.message}\n`);
|
|
444
|
+
printProviderHelp();
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Load course config
|
|
449
|
+
let config;
|
|
450
|
+
try {
|
|
451
|
+
config = await loadCourseConfig();
|
|
452
|
+
console.log(`š Loaded course: ${config.metadata?.title || 'Untitled'}\n`);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.error(`ā Failed to load course config: ${error.message}`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Find all audio sources from course config and categorize them
|
|
459
|
+
const configSources = findAudioSources(config.structure);
|
|
460
|
+
const generatableSources = categorizeAndFilterSources(configSources);
|
|
461
|
+
|
|
462
|
+
// Also scan slide files for component-level narration (modal/tab audio)
|
|
463
|
+
const componentSources = findComponentNarrationSources();
|
|
464
|
+
|
|
465
|
+
// Merge sources, avoiding duplicates (config sources take precedence)
|
|
466
|
+
const configSrcSet = new Set(generatableSources.map(s => s.src));
|
|
467
|
+
for (const compSource of componentSources) {
|
|
468
|
+
if (!configSrcSet.has(compSource.src)) {
|
|
469
|
+
generatableSources.push(compSource);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (generatableSources.length === 0) {
|
|
474
|
+
console.log('ā¹ļø No narration sources found.');
|
|
475
|
+
console.log(' Options:');
|
|
476
|
+
console.log(' ⢠Slide-level: audio: { src: "@slides/intro.js" } in course config');
|
|
477
|
+
console.log(' ⢠Component-level: export const narration = {...} in slide file');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Filter by slide ID if specified
|
|
482
|
+
if (SLIDE_FILTER) {
|
|
483
|
+
const beforeCount = generatableSources.length;
|
|
484
|
+
const filtered = generatableSources.filter(s => s.slideId === SLIDE_FILTER || s.baseName === SLIDE_FILTER);
|
|
485
|
+
if (filtered.length === 0) {
|
|
486
|
+
console.log(`ā No narration source found for slide: ${SLIDE_FILTER}`);
|
|
487
|
+
console.log(` Available slides: ${generatableSources.map(s => s.slideId || s.baseName).join(', ')}`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
generatableSources.length = 0;
|
|
491
|
+
generatableSources.push(...filtered);
|
|
492
|
+
console.log(`šÆ Filtered to slide: ${SLIDE_FILTER} (${filtered.length} of ${beforeCount} sources)\n`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.log(`š Found ${generatableSources.length} narration source(s)\n`);
|
|
496
|
+
|
|
497
|
+
// Load cache
|
|
498
|
+
const cache = FORCE_REGENERATE ? {} : loadCache();
|
|
499
|
+
const newCache = {};
|
|
500
|
+
|
|
501
|
+
let generated = 0;
|
|
502
|
+
let skipped = 0;
|
|
503
|
+
let noNarration = 0;
|
|
504
|
+
let errors = 0;
|
|
505
|
+
|
|
506
|
+
for (const source of generatableSources) {
|
|
507
|
+
const relativeSrcPath = path.relative(ROOT_DIR, source.sourcePath);
|
|
508
|
+
|
|
509
|
+
// Check if source file exists
|
|
510
|
+
if (!fs.existsSync(source.sourcePath)) {
|
|
511
|
+
console.log(` ā ļø ${source.slideId}: Source not found: ${relativeSrcPath}`);
|
|
512
|
+
errors++;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Parse narration based on source type - returns array of items
|
|
517
|
+
let narrationItems;
|
|
518
|
+
try {
|
|
519
|
+
narrationItems = parseSlideNarration(source.sourcePath, source.baseName);
|
|
520
|
+
|
|
521
|
+
if (!narrationItems) {
|
|
522
|
+
if (VERBOSE) {
|
|
523
|
+
console.log(` āļø ${source.slideId}: No narration export in slide`);
|
|
524
|
+
}
|
|
525
|
+
noNarration++;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
console.log(` ā ${source.slideId}: ${error.message}`);
|
|
530
|
+
errors++;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Process each narration item (slide, modals, tabs)
|
|
535
|
+
for (const item of narrationItems) {
|
|
536
|
+
const { key, text, settings, outputPath } = item;
|
|
537
|
+
const relativeOutPath = path.relative(ROOT_DIR, outputPath);
|
|
538
|
+
const contentHash = hashContent(text + JSON.stringify(settings));
|
|
539
|
+
|
|
540
|
+
// Cache key includes the item key for multi-key narration
|
|
541
|
+
const cacheKey = key === 'slide' ? source.src : `${source.src}#${key}`;
|
|
542
|
+
const cachedHash = cache[cacheKey];
|
|
543
|
+
const outputExists = fs.existsSync(outputPath);
|
|
544
|
+
|
|
545
|
+
if (cachedHash === contentHash && outputExists && !FORCE_REGENERATE) {
|
|
546
|
+
if (VERBOSE) {
|
|
547
|
+
const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
|
|
548
|
+
console.log(` āļø ${label}: Unchanged, skipping`);
|
|
549
|
+
}
|
|
550
|
+
newCache[cacheKey] = contentHash;
|
|
551
|
+
skipped++;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Generate audio
|
|
556
|
+
const sourceLabel = '(slide)';
|
|
557
|
+
const keyLabel = key === 'slide' ? '' : ` [${key}]`;
|
|
558
|
+
console.log(` š ${source.slideId}${keyLabel} ${sourceLabel}`);
|
|
559
|
+
console.log(` ā ${relativeOutPath}`);
|
|
560
|
+
|
|
561
|
+
if (VERBOSE) {
|
|
562
|
+
console.log(` Text: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"`);
|
|
563
|
+
if (Object.keys(settings).length > 0) {
|
|
564
|
+
console.log(` Settings: ${JSON.stringify(settings)}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (DRY_RUN) {
|
|
569
|
+
console.log(' (dry run - skipping generation)');
|
|
570
|
+
generated++;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const audioBuffer = await provider.generateAudio(text, settings);
|
|
576
|
+
|
|
577
|
+
// Ensure output directory exists
|
|
578
|
+
const outputDir = path.dirname(outputPath);
|
|
579
|
+
if (!fs.existsSync(outputDir)) {
|
|
580
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Write audio file
|
|
584
|
+
fs.writeFileSync(outputPath, audioBuffer);
|
|
585
|
+
|
|
586
|
+
// Update cache
|
|
587
|
+
newCache[cacheKey] = contentHash;
|
|
588
|
+
generated++;
|
|
589
|
+
|
|
590
|
+
console.log(` ā
Generated (${(audioBuffer.length / 1024).toFixed(1)} KB)`);
|
|
591
|
+
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.log(` ā Error: ${error.message}`);
|
|
594
|
+
errors++;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Save cache
|
|
600
|
+
if (!DRY_RUN) {
|
|
601
|
+
// Preserve unchanged entries from old cache
|
|
602
|
+
for (const [key, hash] of Object.entries(cache)) {
|
|
603
|
+
if (!(key in newCache)) {
|
|
604
|
+
newCache[key] = hash;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
saveCache(newCache);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Summary
|
|
611
|
+
console.log('\n' + 'ā'.repeat(50));
|
|
612
|
+
const parts = [`${generated} generated`, `${skipped} unchanged`];
|
|
613
|
+
if (noNarration > 0) parts.push(`${noNarration} no export`);
|
|
614
|
+
if (errors > 0) parts.push(`${errors} errors`);
|
|
615
|
+
console.log(`⨠Complete: ${parts.join(', ')}`);
|
|
616
|
+
|
|
617
|
+
if (DRY_RUN) {
|
|
618
|
+
console.log('\n (This was a dry run. No files were modified.)');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (errors > 0) {
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
main().catch(error => {
|
|
627
|
+
console.error(`\nā Fatal error: ${error.message}`);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
});
|