@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
@@ -0,0 +1,76 @@
1
+ import type { SpeechWord } from './contracts/speech.ts'
2
+
3
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
4
+ const LETTER = /[\p{L}\p{N}'’]/u
5
+
6
+ export function isCjk(char: string): boolean {
7
+ return CJK.test(char)
8
+ }
9
+
10
+ /** Words of a text; start and end are offsets in the text, not times. */
11
+ export interface TextWord { text: string, start: number, end: number }
12
+
13
+ export function splitWords(text: string): TextWord[] {
14
+ const words: TextWord[] = []
15
+ let run: TextWord | undefined
16
+ let at = 0
17
+ for (const char of text) {
18
+ if (isCjk(char)) {
19
+ if (run) words.push(run)
20
+ run = undefined
21
+ words.push({ text: char, start: at, end: at + char.length })
22
+ } else if (LETTER.test(char)) {
23
+ run ??= { text: '', start: at, end: at }
24
+ run.text += char
25
+ run.end = at + char.length
26
+ } else if (run) {
27
+ words.push(run)
28
+ run = undefined
29
+ }
30
+ at += char.length
31
+ }
32
+ if (run) words.push(run)
33
+ return words
34
+ }
35
+
36
+ export interface WordSpan { from: number, to: number, start: number, end: number }
37
+
38
+ export interface WrittenWord extends SpeechWord { offset?: number }
39
+
40
+ export function remapWords(words: readonly SpeechWord[], written: string, spoken: string, spans: readonly WordSpan[]): WrittenWord[] {
41
+ const places: { start: number, end: number }[] = []
42
+ let at = 0
43
+ for (const word of words) {
44
+ const index = word.text ? spoken.indexOf(word.text, at) : -1
45
+ if (index < 0) return words.map(w => ({ ...w }))
46
+ places.push({ start: index, end: at = index + word.text.length })
47
+ }
48
+ const writtenAt = (offset: number) => {
49
+ let shift = 0
50
+ for (const span of spans) {
51
+ if (offset < span.from) break
52
+ if (offset < span.to) return span.start
53
+ shift += (span.end - span.start) - (span.to - span.from)
54
+ }
55
+ return offset + shift
56
+ }
57
+ const result: WrittenWord[] = []
58
+ let group: { span: WordSpan, word: WrittenWord } | undefined
59
+ words.forEach((word, i) => {
60
+ const { start, end } = places[i]!
61
+ const span = spans.find(s => start < s.to && end > s.from)
62
+ if (span && group?.span === span) {
63
+ group.word.end = Math.max(group.word.end, word.end)
64
+ return
65
+ }
66
+ group = undefined
67
+ if (span) {
68
+ const placed = { text: written.slice(span.start, span.end), start: word.start, end: word.end, offset: span.start }
69
+ group = { span, word: placed }
70
+ result.push(placed)
71
+ } else {
72
+ result.push({ ...word, offset: writtenAt(start) })
73
+ }
74
+ })
75
+ return result
76
+ }
package/src/speech.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { AigcLabel } from './contracts/speech.ts'
2
+
3
+ export function speechEngineId(name: string): string {
4
+ return name.trim().toLowerCase().replace(/[^a-z0-9]/g, '_')
5
+ }
6
+
7
+ export function aigcLabel(producer: string, produceId: string): AigcLabel {
8
+ return { Label: '1', ContentProducer: producer, ProduceID: produceId, ReservedCode1: '', ContentPropagator: '', PropagateID: '', ReservedCode2: '' }
9
+ }
10
+
11
+ export function aigcString(label: AigcLabel): string {
12
+ return `AIGC=${JSON.stringify(label)}`
13
+ }
@@ -0,0 +1,38 @@
1
+ import type { IRBlock, IRSection } from './ir.ts'
2
+
3
+ export type StepKeys = string[][]
4
+
5
+ export function stepKeys(section: Pick<IRSection, 'blocks' | 'steps' | 'stepIds'>, renderer?: string): StepKeys {
6
+ if (section.stepIds) return section.stepIds.map(id => [`step:${id}`])
7
+ const keys: StepKeys = Array.from({ length: section.steps + 1 }, () => [])
8
+ const add = (step: number, key: string) => step > 0 && keys[step]?.push(key)
9
+ let left = false
10
+ const visit = (blocks: readonly IRBlock[], prefix: string) => {
11
+ for (const block of blocks) {
12
+ if (renderer !== undefined && block.only && !block.only.includes(renderer)) {
13
+ left = true
14
+ continue
15
+ }
16
+ const id = prefix + block.id
17
+ if (block.role === 'visual') {
18
+ for (let k = 0; k <= (block.span ?? 0); k++) add(block.at + k, k === 0 ? id : `${id}+${k}`)
19
+ if (block.until !== undefined) add(block.until, `${id}-`)
20
+ }
21
+ if (block.node.type === 'component' && block.node.children) visit(block.node.children, `${id}/`)
22
+ }
23
+ }
24
+ visit(section.blocks, '')
25
+ return left ? keys.filter((step, i) => i === 0 || step.length > 0) : keys
26
+ }
27
+
28
+ export function stepTrail(keys: StepKeys, step: number): string[] {
29
+ return keys.slice(1, step + 1).flat()
30
+ }
31
+
32
+ export function trailStep(keys: StepKeys, trail: readonly string[]): number {
33
+ const reached = new Set(trail)
34
+ for (let step = keys.length - 1; step > 0; step--) {
35
+ if (keys[step]!.some(key => reached.has(key))) return step
36
+ }
37
+ return 0
38
+ }
package/src/steps.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { StepRange, StepState } from './contracts/steps.ts'
2
+
3
+ export function stepStateAt(range: StepRange, step: number): StepState {
4
+ const reached = step >= range.from
5
+ const done = range.to !== undefined && step >= range.to
6
+ return { active: reached && !done, reached, done }
7
+ }
@@ -0,0 +1,105 @@
1
+ import type { Narration, NarrationNode, TeachingDocument } from './contracts/document.ts'
2
+ import type { TranscriptionRevision } from './contracts/transcription.ts'
3
+ import type { TranscriptionAdoptionDraft, TranscriptionAdoptionPreview } from './contracts/transcription-adoption.ts'
4
+ import { fnv1a64 } from './fingerprint.ts'
5
+ import { validateDocument } from './document.ts'
6
+
7
+ const own = (table: object, key: string) => Object.hasOwn(table, key)
8
+ const shown = (nodes: NarrationNode[]) => nodes.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
9
+ const sameIds = (a: string[], b: string[]) => { const sorted = [...b].sort(); return a.length === b.length && [...a].sort().every((id, index) => id === sorted[index]) }
10
+ const atomKey = (index: number, node: NarrationNode) => `atom:${index}:${fnv1a64(JSON.stringify(node))}`
11
+
12
+ /** Builds one typed candidate from explicit author decisions, without I/O or writing the input document. */
13
+ export function prepareTranscriptionAdoption(document: TeachingDocument, candidate: TranscriptionRevision, draft: TranscriptionAdoptionDraft): TranscriptionAdoptionPreview & { content?: TeachingDocument } {
14
+ const result: TranscriptionAdoptionPreview = { targets: [], conflicts: [], impact: { narrationIds: [], stepIds: [], sharedStepIds: [], forks: [], removedAtoms: [], removedCueTimings: [] } }
15
+ const conflict = (code: string, target?: number, key?: string, stepIds?: string[]) => { result.conflicts.push({ code, ...target !== undefined ? { target } : {}, ...key ? { key } : {}, ...stepIds ? { stepIds } : {} }) }
16
+ if (candidate.transcriptionId !== draft.candidate.id || candidate.revision !== draft.candidate.revision || candidate.source.documentId !== document.id || draft.source.documentId !== document.id) {
17
+ conflict('candidate-document-changed'); return result
18
+ }
19
+ const output = structuredClone(document), changed = new Map<string, Narration>(), targets = new Set<string>(), removedCues = new Set<string>()
20
+ const globalIds = new Set([document.id, ...(['sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions'] as const).flatMap(name => Object.keys(document[name])), ...Object.values(document.narrations).flatMap(narration => narration.content.flatMap(node => node.type === 'cue' ? [node.id] : []))])
21
+ const generate = (kind: string, target: string, extra = '') => `${kind}-${fnv1a64(JSON.stringify([document.id, draft.id, target, extra]))}`
22
+ for (const [index, target] of draft.targets.entries()) {
23
+ const step = own(document.steps, target.stepId) ? document.steps[target.stepId] : undefined
24
+ const section = own(document.sections, target.sectionId) ? document.sections[target.sectionId] : undefined
25
+ if (!step || !section || step.sectionId !== section.id || !section.stepOrder.includes(step.id)) { conflict('target-changed', index); continue }
26
+ const address = JSON.stringify([step.id, target.narrationId ?? null])
27
+ if (targets.has(address)) { conflict('target-repeated', index); continue }
28
+ targets.add(address)
29
+ const before = target.narrationId !== undefined && own(document.narrations, target.narrationId) ? document.narrations[target.narrationId] : undefined
30
+ if (target.narrationId !== undefined ? !before || !step.narrationIds.includes(target.narrationId) : step.narrationIds.length !== 0) { conflict('narration-target-changed', index); continue }
31
+ const refs = before ? Object.values(document.steps).filter(item => item.narrationIds.includes(before.id)).map(item => item.id) : [step.id]
32
+ if (refs.length > 1 && !target.shared) conflict('shared-decision-required', index, undefined, refs)
33
+ if (target.shared === 'edit-shared' && !sameIds(target.affectedStepIds ?? [], refs)) conflict('shared-scope-changed', index, undefined, refs)
34
+ if (target.shared === 'fork' && target.affectedStepIds?.length) conflict('shared-scope-unexpected', index)
35
+ const segments = candidate.result.segments.filter(segment => target.segmentIds.includes(segment.id))
36
+ if (segments.length !== target.segmentIds.length) { conflict('segment-missing', index); continue }
37
+ const sourceText = segments.map(segment => segment.text).join('\n')
38
+ const atoms = (before?.content ?? []).flatMap((node, position) => node.type === 'text' ? [] : [{ key: atomKey(position, node), node }])
39
+ const atomMap = new Map(atoms.map(atom => [atom.key, atom.node])), preserved = new Set<string>(), discarded = new Set(target.discardAtoms ?? [])
40
+ let proposed: NarrationNode[] = []
41
+ if (target.content === undefined && sourceText === shown(before?.content ?? []) && !discarded.size) {
42
+ proposed = structuredClone(before?.content ?? []); atoms.forEach(atom => preserved.add(atom.key))
43
+ } else if (target.content === undefined) proposed = [{ type: 'text', text: sourceText }]
44
+ else for (const part of target.content) {
45
+ if (part.type === 'text') proposed.push({ type: 'text', text: part.text })
46
+ else {
47
+ const atom = atomMap.get(part.key)
48
+ if (!atom) conflict('atom-unknown', index, part.key)
49
+ else if (preserved.has(part.key) || discarded.has(part.key)) conflict('atom-repeated', index, part.key)
50
+ else { preserved.add(part.key); proposed.push(structuredClone(atom)) }
51
+ }
52
+ }
53
+ for (const key of discarded) {
54
+ const atom = atomMap.get(key)
55
+ if (!atom) conflict('atom-unknown', index, key)
56
+ else { result.impact.removedAtoms.push(key); if (atom.type === 'cue') removedCues.add(atom.id) }
57
+ }
58
+ for (const atom of atoms) if (!preserved.has(atom.key) && !discarded.has(atom.key)) conflict('protected-atom-unresolved', index, atom.key)
59
+ const fork = Boolean(before && target.shared === 'fork')
60
+ const outputId = !before || fork ? generate('narration', address) : before.id
61
+ const cueChanges: { from: string, to: string }[] = []
62
+ if ((!before || fork) && globalIds.has(outputId)) conflict('generated-id-collision', index, outputId)
63
+ if (fork) proposed = proposed.map(node => {
64
+ if (node.type !== 'cue') return node
65
+ const id = generate('cue', address, node.id)
66
+ if (globalIds.has(id)) conflict('generated-id-collision', index, id)
67
+ globalIds.add(id); cueChanges.push({ from: node.id, to: id })
68
+ if (own(document.production.cueTimings ?? {}, node.id)) {
69
+ output.production.cueTimings ??= {}
70
+ output.production.cueTimings[id] = structuredClone(document.production.cueTimings![node.id]!)
71
+ }
72
+ return { ...node, id }
73
+ })
74
+ const narration: Narration = { ...before, id: outputId, content: proposed, provenance: { type: 'transcription', transcriptionId: candidate.transcriptionId, revision: candidate.revision, assetId: candidate.audio.assetId, audioSha256: candidate.audio.sha256, segmentIds: segments.map(segment => segment.id) } }
75
+ const prior = changed.get(outputId)
76
+ if (prior && JSON.stringify(prior) !== JSON.stringify(narration)) conflict('shared-content-conflict', index)
77
+ else { changed.set(outputId, narration); Object.defineProperty(output.narrations, outputId, { value: narration, enumerable: true, configurable: true, writable: true }) }
78
+ if (!before) output.steps[step.id]!.narrationIds.push(outputId)
79
+ else if (fork) {
80
+ output.steps[step.id]!.narrationIds = output.steps[step.id]!.narrationIds.map(id => id === before.id ? outputId : id)
81
+ if (own(document.production.narrationOverrides ?? {}, before.id)) {
82
+ output.production.narrationOverrides ??= {}
83
+ output.production.narrationOverrides[outputId] = structuredClone(document.production.narrationOverrides![before.id]!)
84
+ }
85
+ result.impact.forks.push({ from: before.id, to: outputId, stepId: step.id, cues: cueChanges })
86
+ }
87
+ globalIds.add(outputId)
88
+ result.impact.narrationIds.push(outputId)
89
+ result.impact.stepIds.push(...fork ? [step.id] : refs)
90
+ if (refs.length > 1 && !fork) result.impact.sharedStepIds.push(...refs)
91
+ result.targets.push({ sectionId: section.id, stepId: step.id, narrationId: before?.id, outputNarrationId: outputId, segmentIds: segments.map(segment => segment.id), sourceText, before: structuredClone(before?.content ?? []), proposed: structuredClone(proposed), atoms: structuredClone(atoms) })
92
+ }
93
+ const remainingCues = new Set(Object.values(output.narrations).flatMap(narration => narration.content.flatMap(node => node.type === 'cue' ? [node.id] : [])))
94
+ for (const id of removedCues) if (!remainingCues.has(id) && own(document.production.cueTimings ?? {}, id) && !draft.removeCueTimings?.includes(id)) conflict('cue-timing-confirmation-required', undefined, id)
95
+ for (const id of draft.removeCueTimings ?? []) {
96
+ if (!removedCues.has(id) || remainingCues.has(id) || !own(document.production.cueTimings ?? {}, id)) conflict('cue-timing-unexpected', undefined, id)
97
+ else { delete output.production.cueTimings![id]; result.impact.removedCueTimings.push(id) }
98
+ }
99
+ for (const field of ['narrationIds', 'stepIds', 'sharedStepIds', 'removedAtoms'] as const) result.impact[field] = [...new Set(result.impact[field])]
100
+ if (!result.conflicts.length) {
101
+ const checked = validateDocument(output)
102
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) for (const issue of checked.issues.filter(issue => issue.severity === 'error')) conflict('document-invalid', undefined, issue.path)
103
+ }
104
+ return { ...result, ...result.conflicts.length ? {} : { content: output } }
105
+ }
@@ -0,0 +1,8 @@
1
+ /** Decodes a URI component; a malformed one is returned as written. */
2
+ export function safeDecodeURIComponent(value: string): string {
3
+ try {
4
+ return decodeURIComponent(value)
5
+ } catch {
6
+ return value
7
+ }
8
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** A URL with a scheme (`https:`, `data:`, `mailto:`) or a protocol-relative one (`//host/path`). */
2
+ export const ABSOLUTE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
3
+
4
+ /** A URL that names no local file: an absolute URL or a bare fragment (`#id`). */
5
+ export const NON_FILE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
6
+
7
+ /**
8
+ * A slug for a URL path segment or fragment: letters and digits, each other run of characters one `-`.
9
+ * The web renderer's page paths and the IR runtime's heading IDs; section IDs follow the compiler's own rule.
10
+ */
11
+ export function urlSlug(text: string): string {
12
+ return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '')
13
+ }
package/src/views.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { irBlocks } from './blocks.ts'
2
+ import type { IRBlock, IRDocument, IRSection } from './ir.ts'
3
+
4
+ /**
5
+ * Closes the gaps that removed blocks leave in a section's steps. With `stepMap`, the section records
6
+ * the new number of each old step, so positions in the whole document can be mapped to it.
7
+ */
8
+ export function compactSteps(section: IRSection, options: { stepMap?: boolean } = {}): IRSection {
9
+ // Explicit authoring steps can carry narration and production timing even
10
+ // when this renderer has no visual change. Keep their identities and states.
11
+ if (section.stepIds) return options.stepMap ? { ...section, stepMap: section.stepIds.map((_, index) => index) } : section
12
+ const used = new Set([0])
13
+ for (const block of irBlocks(section.blocks)) {
14
+ if (block.role !== 'visual') continue
15
+ for (let step = block.at; step <= block.at + (block.span ?? 0); step++) used.add(step)
16
+ if (block.until !== undefined) used.add(block.until)
17
+ }
18
+ if (used.size > section.steps) return section
19
+ const stepMap: number[] = []
20
+ for (let step = 0, n = -1; step <= section.steps; step++) stepMap.push(used.has(step) ? ++n : n)
21
+ const map = (step: number) => stepMap[Math.min(step, section.steps)]!
22
+ const renumber = (blocks: IRBlock[]): IRBlock[] => blocks.map(block => ({
23
+ ...block,
24
+ at: map(block.at),
25
+ ...block.until === undefined ? {} : { until: map(block.until) },
26
+ ...block.node.type === 'component' && block.node.children ? { node: { ...block.node, children: renumber(block.node.children) } } : {},
27
+ }))
28
+ return { ...section, steps: map(section.steps), ...options.stepMap ? { stepMap } : {}, blocks: renumber(section.blocks) }
29
+ }
30
+
31
+ /** The document as one renderer sees it: blocks limited to other renderers are gone, the steps closed up. */
32
+ export function viewFor(doc: IRDocument, renderer: string): IRDocument {
33
+ let changed = false
34
+ const keep = (blocks: IRBlock[]): IRBlock[] => blocks.flatMap((block) => {
35
+ if (block.only && !block.only.includes(renderer)) {
36
+ changed = true
37
+ return []
38
+ }
39
+ return block.node.type === 'component' && block.node.children ? [{ ...block, node: { ...block.node, children: keep(block.node.children) } }] : [block]
40
+ })
41
+ const sections = doc.sections.map((section) => {
42
+ changed = false
43
+ const blocks = keep(section.blocks)
44
+ return changed ? compactSteps({ ...section, blocks }, { stepMap: true }) : section
45
+ })
46
+ return { ...doc, sections }
47
+ }
package/src/voice.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { irBlocks } from './blocks.ts'
2
+ import { isRecord } from './errors.ts'
3
+ import { fnv1a64 } from './fingerprint.ts'
4
+ import type { IRDocument, IRSection, MdastNode } from './ir.ts'
5
+ import { stepKeys } from './step-keys.ts'
6
+ import type { SpeechVoice } from './contracts/speech.ts'
7
+ import type { ResolvedVoice, VoiceOverrides, VoiceTarget } from './contracts/voice.ts'
8
+
9
+ export function speechVoice(value: unknown): SpeechVoice | undefined {
10
+ if (!isRecord(value) || typeof value.voice !== 'string' || !value.voice.trim()) return undefined
11
+ if (value.engine !== undefined && (typeof value.engine !== 'string' || !value.engine.trim())) return undefined
12
+ if (value.rate !== undefined && (typeof value.rate !== 'number' || !Number.isFinite(value.rate) || value.rate <= 0)) return undefined
13
+ if (value.style !== undefined && typeof value.style !== 'string') return undefined
14
+ return { voice: value.voice, ...typeof value.engine === 'string' ? { engine: value.engine } : {}, ...typeof value.rate === 'number' ? { rate: value.rate } : {}, ...typeof value.style === 'string' ? { style: value.style } : {} }
15
+ }
16
+
17
+ export function voiceTarget(section: Pick<IRSection, 'id' | 'steps' | 'stepIds' | 'blocks'>, step: number): VoiceTarget {
18
+ if (!Number.isSafeInteger(step) || step < 0 || step > section.steps) throw new Error('voice-target')
19
+ return { section: section.id, step, binding: fnv1a64(JSON.stringify([section.id, stepKeys(section)])) }
20
+ }
21
+
22
+ export function documentVoice(doc: IRDocument): SpeechVoice | undefined {
23
+ if (doc.production) return doc.production.defaults.voiceSource?.type === 'tts' ? doc.production.defaults.voiceSource.voice : undefined
24
+ return speechVoice(doc.config.renderers.lecture?.voice)
25
+ }
26
+
27
+ export function voiceOverrides(doc: IRDocument): VoiceOverrides {
28
+ if (doc.production) {
29
+ const production = doc.production
30
+ const pages = Object.fromEntries(Object.entries(production.sectionOverrides ?? {}).flatMap(([id, value]) => value.voiceSource?.type === 'tts' ? [[id, value.voiceSource.voice]] : []))
31
+ const steps = doc.sections.flatMap(section => (section.stepIds ?? []).flatMap((id, step) => {
32
+ const source = production.stepOverrides[id]?.voiceSource
33
+ return source?.type === 'tts' ? [{ ...voiceTarget(section, step), voice: source.voice }] : []
34
+ }))
35
+ return { pages, steps }
36
+ }
37
+ const raw = doc.config.renderers.lecture?.voiceOverrides
38
+ if (raw === undefined) return { pages: {}, steps: [] }
39
+ if (!isRecord(raw) || raw.pages !== undefined && !isRecord(raw.pages) || raw.steps !== undefined && !Array.isArray(raw.steps)) throw new Error('voice-settings-invalid')
40
+ const pages: Record<string, SpeechVoice> = Object.create(null)
41
+ for (const [section, value] of Object.entries(raw.pages ?? {})) {
42
+ const voice = speechVoice(value)
43
+ if (!voice) throw new Error('voice-settings-invalid')
44
+ pages[section] = voice
45
+ }
46
+ const steps: VoiceOverrides['steps'] = []
47
+ const used = new Set<string>()
48
+ for (const value of raw.steps ?? []) {
49
+ if (!isRecord(value) || typeof value.section !== 'string' || typeof value.step !== 'number' || !Number.isSafeInteger(value.step) || value.step < 0 || typeof value.binding !== 'string') throw new Error('voice-settings-invalid')
50
+ const voice = speechVoice(value.voice), key = JSON.stringify([value.section, value.step])
51
+ if (!voice || used.has(key)) throw new Error('voice-settings-invalid')
52
+ used.add(key)
53
+ steps.push({ section: value.section, step: value.step, binding: value.binding, voice })
54
+ }
55
+ return { pages, steps }
56
+ }
57
+
58
+ function advances(node: MdastNode): boolean {
59
+ if (node.type === 'presmiumRole' && node.plugin === 'presmium.lecture' && node.name === 'cue' && isRecord(node.options) && typeof node.options.value === 'string' && /^next(?:\s|$)/.test(node.options.value.trim())) return true
60
+ return node.children?.some(advances) ?? false
61
+ }
62
+
63
+ /** A cue can change the visual step inside one audio clip. That clip cannot yet take independent step voices. */
64
+ export function stepVoiceReason(doc: IRDocument, section: IRSection): ResolvedVoice['reason'] | undefined {
65
+ if ([...irBlocks(section.blocks)].some(block => block.role === 'narration' && block.node.type === 'markdown' && advances(block.node.mdast))) return 'cue-step'
66
+ const expected = voiceTarget(section, 0).binding
67
+ if (voiceOverrides(doc).steps.some(entry => entry.section === section.id && (entry.binding !== expected || entry.step > section.steps))) return 'step-binding-stale'
68
+ return undefined
69
+ }
70
+
71
+ export function resolvedVoice(doc: IRDocument, section: IRSection, step: number): ResolvedVoice {
72
+ if (doc.production) {
73
+ const target = voiceTarget(section, step), production = doc.production
74
+ const stepId = section.stepIds?.[step]
75
+ const own = stepId ? production.stepOverrides[stepId]?.voiceSource : undefined
76
+ const page = production.sectionOverrides?.[section.id]?.voiceSource
77
+ const source = own ?? page ?? production.defaults.voiceSource
78
+ const reason = source?.type === 'recording' ? 'recording-unavailable' as const : stepVoiceReason(doc, section)
79
+ return { target, ...source?.type === 'tts' ? { voice: source.voice, ...own ? { override: source.voice } : {} } : {}, inheritedFrom: own ? 'step' : page ? 'page' : source ? 'document' : 'unset', editable: !reason, ...reason ? { reason } : {} }
80
+ }
81
+ const target = voiceTarget(section, step), overrides = voiceOverrides(doc)
82
+ const reason = stepVoiceReason(doc, section)
83
+ const own = !reason && overrides.steps.find(entry => entry.section === section.id && entry.step === step && entry.binding === target.binding)?.voice
84
+ const page = overrides.pages[section.id], fallback = documentVoice(doc)
85
+ return { target, ...own || page || fallback ? { voice: own || page || fallback } : {}, inheritedFrom: own ? 'step' : page ? 'page' : fallback ? 'document' : 'unset', ...own ? { override: own } : {}, editable: !reason, ...reason ? { reason } : {} }
86
+ }