@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.
- package/LICENSE +202 -0
- package/README.md +32 -2
- package/package.json +41 -3
- package/src/access.ts +18 -0
- package/src/anchors.ts +58 -0
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +19 -0
- package/src/checks.ts +35 -0
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +76 -0
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +118 -0
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/operations.ts +8 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +386 -0
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +108 -0
- package/src/contracts/service.ts +8 -0
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +149 -0
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +35 -0
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +15 -0
- package/src/events.ts +32 -0
- package/src/execution.ts +25 -0
- package/src/fingerprint.ts +31 -0
- package/src/follow.ts +33 -0
- package/src/git.ts +12 -0
- package/src/html.ts +41 -0
- package/src/index.ts +82 -0
- package/src/ir.ts +367 -0
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +190 -0
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +81 -0
- package/src/logging.ts +11 -0
- package/src/lower-myst.ts +312 -0
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -0
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/strip-types.mjs +15 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1220 -0
- package/src/props.ts +17 -0
- package/src/published.ts +11 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +96 -0
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/requests.ts +28 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +454 -0
- package/src/services.ts +146 -0
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- 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
|
+
}
|
package/src/step-keys.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|