@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.2

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 (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/presmium-lecture.mjs +12 -0
  4. package/package.json +68 -3
  5. package/src/client/ChapterList.tsx +50 -0
  6. package/src/client/LectureView.css +21 -0
  7. package/src/client/LectureView.tsx +84 -0
  8. package/src/client/PlayLectureButton.tsx +21 -0
  9. package/src/client/PlayerPanel.css +120 -0
  10. package/src/client/PlayerPanel.tsx +194 -0
  11. package/src/client/RecorderPanel.css +46 -0
  12. package/src/client/RecorderPanel.tsx +282 -0
  13. package/src/client/TranscriptList.tsx +28 -0
  14. package/src/client/clock.ts +169 -0
  15. package/src/client/env.d.ts +7 -0
  16. package/src/client/media.ts +86 -0
  17. package/src/client/playback.ts +140 -0
  18. package/src/client/player.ts +202 -0
  19. package/src/client/plugin.ts +33 -0
  20. package/src/client/pointer-ink.ts +143 -0
  21. package/src/client/production-playback.ts +188 -0
  22. package/src/client/recorder.ts +245 -0
  23. package/src/client/source.ts +109 -0
  24. package/src/client/state.ts +45 -0
  25. package/src/client/viewer.ts +37 -0
  26. package/src/domain/audience.ts +108 -0
  27. package/src/domain/caption-timing.ts +59 -0
  28. package/src/domain/captions.ts +59 -0
  29. package/src/domain/config.ts +67 -0
  30. package/src/domain/drift.ts +159 -0
  31. package/src/domain/editing.ts +112 -0
  32. package/src/domain/format.ts +13 -0
  33. package/src/domain/index.ts +50 -0
  34. package/src/domain/messages.ts +324 -0
  35. package/src/domain/narration.ts +160 -0
  36. package/src/domain/plan.ts +319 -0
  37. package/src/domain/production-audio.ts +39 -0
  38. package/src/domain/production.ts +136 -0
  39. package/src/domain/recording.ts +130 -0
  40. package/src/domain/script-track.ts +176 -0
  41. package/src/domain/timing.ts +107 -0
  42. package/src/domain/track.ts +302 -0
  43. package/src/domain/translation.ts +34 -0
  44. package/src/domain/vtt.ts +68 -0
  45. package/src/domain/words.ts +114 -0
  46. package/src/domain/writeback.ts +129 -0
  47. package/src/node/audio-authoring.ts +53 -0
  48. package/src/node/check.ts +93 -0
  49. package/src/node/cli.ts +82 -0
  50. package/src/node/clips.ts +10 -0
  51. package/src/node/engines.ts +43 -0
  52. package/src/node/export.ts +331 -0
  53. package/src/node/ffmpeg.ts +76 -0
  54. package/src/node/latest-ir.ts +54 -0
  55. package/src/node/plan.ts +35 -0
  56. package/src/node/presmium.ts +59 -0
  57. package/src/node/production-assets.ts +112 -0
  58. package/src/node/production.ts +102 -0
  59. package/src/node/renderer.ts +182 -0
  60. package/src/node/roles.ts +23 -0
  61. package/src/node/routes/production.ts +51 -0
  62. package/src/node/routes/recordings.ts +122 -0
  63. package/src/node/routes/serve-file.ts +38 -0
  64. package/src/node/routes/served-deck.ts +61 -0
  65. package/src/node/routes/server.ts +12 -0
  66. package/src/node/routes/tracks.ts +98 -0
  67. package/src/node/routes/voiceover.ts +23 -0
  68. package/src/node/store.ts +192 -0
  69. package/src/node/synthesize.ts +190 -0
  70. package/src/node/tools.ts +170 -0
  71. package/src/node/transcribe.ts +53 -0
  72. package/src/node/voice-generation.ts +15 -0
  73. package/src/node/voice-source.ts +34 -0
  74. package/src/node/voiceover-execution.ts +223 -0
@@ -0,0 +1,68 @@
1
+ import { timedEvents, trackChapters } from './timing.ts'
2
+ import type { LectureTrack, Utterance } from './track.ts'
3
+ import { trackDuration } from './track.ts'
4
+ import { isStale } from './translation.ts'
5
+ import { trackCaptionGroups } from './captions.ts'
6
+
7
+ export function vttTime(ms: number): string {
8
+ const total = Math.max(0, Math.round(ms))
9
+ const pad = (n: number, width = 2) => String(n).padStart(width, '0')
10
+ return `${pad(Math.floor(total / 3_600_000))}:${pad(Math.floor(total / 60_000) % 60)}:${pad(Math.floor(total / 1000) % 60)}.${pad(total % 1000, 3)}`
11
+ }
12
+
13
+ function cueText(text: string): string {
14
+ return text.replace(/\r?\n\s*\n/g, '\n').replace(/-->/g, '->').trim()
15
+ }
16
+
17
+ export function captionLanguages(track: LectureTrack): string[] {
18
+ return [...new Set([track.lang, ...track.utterances.flatMap(u => Object.keys(u.translations ?? {}))])]
19
+ }
20
+
21
+ /** A sentence's caption in a language: its text, or its translation; undefined when it has none in that language. */
22
+ export function captionText(track: LectureTrack, utterance: Utterance, lang: string): string | undefined {
23
+ return lang === track.lang ? utterance.text : utterance.translations?.[lang]?.text
24
+ }
25
+
26
+ export interface Captions {
27
+ lang: string
28
+ vtt: string
29
+ missing: string[]
30
+ stale: string[]
31
+ }
32
+
33
+ export function captionsVtt(track: LectureTrack, lang: string): Captions {
34
+ const cues: string[] = []
35
+ const missing: string[] = []
36
+ const stale: string[] = []
37
+ for (const u of track.utterances) {
38
+ const original = lang === track.lang
39
+ const text = captionText(track, u, lang)
40
+ if (text === undefined) {
41
+ missing.push(u.id)
42
+ continue
43
+ }
44
+ if (!original && isStale(u, lang)) {
45
+ stale.push(u.id)
46
+ cues.push(`NOTE stale: the original of ${u.id} changed after it was translated`)
47
+ }
48
+ if (!original) cues.push(`${u.id}\n${vttTime(u.t)} --> ${vttTime(u.t + Math.max(u.d, 1))}\n${cueText(text)}`)
49
+ }
50
+ if (lang === track.lang) for (const group of trackCaptionGroups(track)) cues.push(`${group.id}\n${vttTime(group.t)} --> ${vttTime(group.t + Math.max(group.d, 1))}\n${cueText(group.text)}`)
51
+ return { lang, vtt: `WEBVTT\n\n${cues.join('\n\n')}\n`, missing, stale }
52
+ }
53
+
54
+ export function chaptersVtt(track: LectureTrack, titles: (section: string) => string | undefined = () => undefined): string {
55
+ const list = trackChapters(timedEvents(track), titles)
56
+ const end = trackDuration(track)
57
+ const cues = list.map((chapter, i) => {
58
+ const until = Math.max(list[i + 1]?.t ?? end, chapter.t + 1)
59
+ return `${vttTime(chapter.t)} --> ${vttTime(until)}\n${cueText(chapter.title)}`
60
+ })
61
+ return `WEBVTT\n\n${cues.join('\n\n')}\n`
62
+ }
63
+
64
+ export function eventsVtt(track: LectureTrack): string {
65
+ const json = (value: unknown) => JSON.stringify(value).replace(/-->/g, '--\\u003e')
66
+ const cues = timedEvents(track).map(event => `${vttTime(event.t)} --> ${vttTime(event.t + 1)}\n${json({ type: event.type, data: event.data })}`)
67
+ return `WEBVTT\n\n${cues.join('\n\n')}\n`
68
+ }
@@ -0,0 +1,114 @@
1
+ import { isCjk, splitWords } from '@lincyaw/kernel'
2
+ import type { SpeechWord } from '@lincyaw/kernel'
3
+ import type { Utterance, Word } from './track.ts'
4
+
5
+ export function isWide(char: string | undefined): boolean {
6
+ return Boolean(char && (isCjk(char) || /[ -〿＀-￯]/u.test(char)))
7
+ }
8
+
9
+ export function locate(words: readonly string[], text: string): ({ start: number, end: number } | undefined)[] {
10
+ const places: ({ start: number, end: number } | undefined)[] = []
11
+ let at = 0
12
+ let lost = false
13
+ for (const word of words) {
14
+ const index = lost || !word ? -1 : text.indexOf(word, at)
15
+ if (index < 0) {
16
+ lost = true
17
+ places.push(undefined)
18
+ continue
19
+ }
20
+ places.push({ start: index, end: at = index + word.length })
21
+ }
22
+ return places
23
+ }
24
+
25
+ export function wordIndexAt(words: readonly { offset?: number }[], offset: number, length: number): number {
26
+ if (words.every(w => w.offset !== undefined)) {
27
+ const index = words.findIndex(w => w.offset! >= offset)
28
+ return index === -1 ? words.length : index
29
+ }
30
+ return Math.min(words.length, Math.round(words.length * offset / Math.max(1, length)))
31
+ }
32
+
33
+ export function charOffset(text: string, offset: number): number {
34
+ return [...text.slice(0, offset)].length
35
+ }
36
+
37
+ export function trackWords(words: readonly SpeechWord[], t: number): Word[] {
38
+ return words.map((w): Word => {
39
+ const start = Math.round(t + w.start * 1000), end = Math.round(t + w.end * 1000)
40
+ return [start, Math.max(0, end - start), w.text]
41
+ })
42
+ }
43
+
44
+ function common(a: readonly string[], b: readonly string[]): [number, number][] {
45
+ const table = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
46
+ for (let i = a.length - 1; i >= 0; i--) {
47
+ for (let j = b.length - 1; j >= 0; j--) table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
48
+ }
49
+ const pairs: [number, number][] = []
50
+ for (let i = 0, j = 0; i < a.length && j < b.length;) {
51
+ if (a[i] === b[j]) pairs.push([i++, j++])
52
+ else if (table[i + 1]![j]! >= table[i]![j + 1]!) i++
53
+ else j++
54
+ }
55
+ return pairs
56
+ }
57
+
58
+ export function realign(utterance: Utterance, text: string): { utterance: Utterance, anchor: (offset: number) => number } {
59
+ const before = utterance.words ?? []
60
+ const next = splitWords(text)
61
+ if (before.length === 0) {
62
+ const [from, to] = [Math.max(1, [...utterance.text].length), Math.max(1, [...text].length)]
63
+ return { utterance: { ...utterance, text, alignment: 'proportional' }, anchor: offset => Math.round(offset * to / from) }
64
+ }
65
+ const pairs = common(before.map(w => w[2]), next.map(w => w.text))
66
+ const kept = new Map(pairs.map(([i, j]) => [j, i]))
67
+ const end = utterance.t + utterance.d
68
+ const words: Word[] = []
69
+ for (let j = 0; j < next.length;) {
70
+ const old = kept.get(j)
71
+ if (old !== undefined) {
72
+ words.push([before[old]![0], before[old]![1], next[j]!.text])
73
+ j++
74
+ continue
75
+ }
76
+ let k = j
77
+ while (k < next.length && !kept.has(k)) k++
78
+ const from = words.length ? words.at(-1)![0] + words.at(-1)![1] : utterance.t
79
+ const until = k < next.length ? before[kept.get(k)!]![0] : end
80
+ const span = Math.max(0, until - from)
81
+ const chars = next.slice(j, k).map(w => [...w.text].length)
82
+ const total = chars.reduce((a, b) => a + b, 0)
83
+ let at = from
84
+ for (let n = j; n < k; n++) {
85
+ const d = Math.round(span * chars[n - j]! / total)
86
+ words.push([at, d, next[n]!.text])
87
+ at += d
88
+ }
89
+ j = k
90
+ }
91
+ const anchor = (offset: number) => {
92
+ if (offset >= before.length) return next.length
93
+ const pair = pairs.find(([i]) => i >= offset)
94
+ if (pair && pair[0] === offset) return pair[1]
95
+ const previous = [...pairs].reverse().find(([i]) => i < offset)
96
+ return Math.min(next.length, previous ? previous[1] + 1 : 0)
97
+ }
98
+ return { utterance: { ...utterance, text, words, alignment: 'proportional' }, anchor }
99
+ }
100
+
101
+ export function reanchor(text: string, before: readonly Word[], after: readonly Word[]): (offset: number) => number {
102
+ const old = locate(before.map(w => w[2]), text)
103
+ const now = locate(after.map(w => w[2]), text)
104
+ return (offset) => {
105
+ if (offset >= before.length) return after.length
106
+ const place = old[offset]
107
+ if (!place || !now.every(Boolean)) return Math.min(offset, after.length)
108
+ let index = 0
109
+ now.forEach((p, i) => {
110
+ if (p!.start <= place.start) index = i
111
+ })
112
+ return index
113
+ }
114
+ }
@@ -0,0 +1,129 @@
1
+ import { irBlocks } from '@lincyaw/kernel'
2
+ import type { IRDocument, SourceRange } from '@lincyaw/kernel'
3
+ import type { LecturePlan } from './plan.ts'
4
+ import { anchorOf } from './timing.ts'
5
+ import type { LectureTrack, Utterance } from './track.ts'
6
+ import { isWide, locate } from './words.ts'
7
+
8
+ function optionValue(value: string): string {
9
+ return /^[^\s"'{}=]+$/.test(value) ? value : `"${value.replace(/"/g, '\'')}"`
10
+ }
11
+
12
+ function formatCue(command: string, options: Record<string, string | number | undefined> = {}): string {
13
+ const written = Object.entries(options).filter(([, v]) => v !== undefined && v !== '').map(([key, v]) => ` ${key}=${optionValue(String(v))}`).join('')
14
+ return `{cue${written}}\`${command}\``
15
+ }
16
+
17
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)
18
+
19
+ function blockIdOf(key: unknown): string | undefined {
20
+ const value = text(key)
21
+ return value?.includes('/') ? value.slice(value.indexOf('/') + 1) : undefined
22
+ }
23
+
24
+ const SELECTOR = /^\[data-section="[^"]*"\] \.pm-block\[data-block="([^"]+)"\]\s*(.*)$/
25
+
26
+ export function cueFor(type: string, data: Record<string, unknown>, at: { section?: string, step: number }): string | undefined {
27
+ const block = blockIdOf(data.block)
28
+ switch (type) {
29
+ case 'go':
30
+ return data.section === at.section && typeof data.step === 'number' && data.step > at.step ? formatCue('next') : undefined
31
+ case 'spot':
32
+ case 'hint': {
33
+ const selected = SELECTOR.exec(text(data.selector) ?? '')
34
+ if (selected) return formatCue(type, { to: selected[1], sel: selected[2] })
35
+ return block ? formatCue(type, { to: block, lines: text(data.lines) }) : undefined
36
+ }
37
+ case 'point':
38
+ return block ? formatCue('point', { to: block, lines: typeof data.line === 'number' ? data.line : undefined }) : undefined
39
+ case 'ink':
40
+ return block && !data.clear ? formatCue('ink', { to: block, lines: text(data.lines) }) : undefined
41
+ case 'run':
42
+ return text(data.block) ? formatCue('run', { to: text(data.block) }) : undefined
43
+ case 'click':
44
+ return block && text(data.action) ? formatCue(`click ${text(data.action)}`, { to: block }) : undefined
45
+ case 'mark':
46
+ return text(data.label) ? formatCue(`mark ${text(data.label)}`) : undefined
47
+ case 'pause':
48
+ return typeof data.ms === 'number' && data.ms > 0 && !data.hold ? formatCue(`pause ${Math.round(data.ms)}ms`) : undefined
49
+ default:
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ function textOffsetAt(u: Utterance, offset: number): number {
55
+ const words = u.words ?? []
56
+ if (words.length === 0) return [...u.text].slice(0, Math.max(0, offset)).join('').length
57
+ if (offset >= words.length) return u.text.length
58
+ const located = locate(words.map(w => w[2]), u.text)[Math.max(0, offset)]
59
+ return located?.start ?? Math.round(u.text.length * offset / words.length)
60
+ }
61
+
62
+ function joined(parts: string[]): string {
63
+ return parts.reduce((all, part) => (!all ? part : isWide(all.at(-1)) || isWide(part[0]) ? all + part : `${all} ${part}`), '')
64
+ }
65
+
66
+ export interface DraftStep {
67
+ step: number
68
+ draft: string
69
+ utterances: string[]
70
+ }
71
+
72
+ export interface DraftSection {
73
+ section: string
74
+ steps: DraftStep[]
75
+ }
76
+
77
+ export interface NarrationDraft {
78
+ sections: DraftSection[]
79
+ skipped: number
80
+ }
81
+
82
+ export function narrationDraft(track: LectureTrack, section?: string): NarrationDraft {
83
+ const utterances = new Map(track.utterances.map(u => [u.id, u]))
84
+ const cues = new Map<string, { offset: number, role: string, index: number }[]>()
85
+ let skipped = 0
86
+ track.events.forEach(([, type, data], index) => {
87
+ const at = anchorOf(data)
88
+ const u = at && utterances.get(at[0])
89
+ if (!u) return
90
+ const role = cueFor(type, data, { section: u.section, step: u.step ?? 0 })
91
+ if (!role) {
92
+ if (type !== 'go') skipped++
93
+ return
94
+ }
95
+ cues.set(u.id, [...cues.get(u.id) ?? [], { offset: textOffsetAt(u, at![1]), role, index }])
96
+ })
97
+ const said = (u: Utterance) => (cues.get(u.id) ?? [])
98
+ .sort((a, b) => b.offset - a.offset || b.index - a.index)
99
+ .reduce((written, cue) => written.slice(0, cue.offset) + cue.role + written.slice(cue.offset), u.text)
100
+ const sections: DraftSection[] = []
101
+ for (const u of [...track.utterances].sort((a, b) => a.t - b.t)) {
102
+ if (!u.section || (section && u.section !== section)) continue
103
+ let entry = sections.find(s => s.section === u.section)
104
+ if (!entry) sections.push(entry = { section: u.section, steps: [] })
105
+ const step = u.step ?? 0
106
+ let at = entry.steps.find(s => s.step === step)
107
+ if (!at) entry.steps.push(at = { step, draft: '', utterances: [] })
108
+ at.utterances.push(u.id)
109
+ at.draft = joined([at.draft, said(u)])
110
+ }
111
+ for (const entry of sections) entry.steps.sort((a, b) => a.step - b.step)
112
+ return { sections, skipped }
113
+ }
114
+
115
+ export interface NarrationSource {
116
+ narration: SourceRange[]
117
+ after?: SourceRange & { block: string }
118
+ }
119
+
120
+ export function narrationSource(doc: IRDocument, plan: LecturePlan, section: string, step: number): NarrationSource {
121
+ const planned = plan.sections.find(s => s.id === section)
122
+ const ir = doc.sections.find(s => s.id === section)
123
+ if (!planned || !ir) return { narration: [] }
124
+ const range = (source: { file: string, line: number, endLine?: number }): SourceRange => ({ file: source.file, startLine: source.line, endLine: source.endLine ?? source.line })
125
+ const narration = planned.paragraphs.filter(p => p.source && planned.webStep(p.step) === step).map(p => range(p.source!))
126
+ if (narration.length > 0) return { narration }
127
+ const block = [...irBlocks(ir.blocks)].filter(b => b.role === 'visual' && planned.webStep(b.at) <= step).at(-1)
128
+ return { narration, ...block ? { after: { block: block.id, ...range(block.source) } } : {} }
129
+ }
@@ -0,0 +1,53 @@
1
+ import { ProductionAudioRefusal } from '@lincyaw/kernel'
2
+ import type { NodeServiceContext, ProductionAudioAuthoring } from '@lincyaw/kernel'
3
+ import { irRevision } from '@lincyaw/kernel/node'
4
+ import { placeProductionAudio } from '../domain/production-audio.ts'
5
+ import { latestDoc } from './latest-ir.ts'
6
+ import { inspectProductionAsset, inspectProductionAssets, inspectProductionAudioBytes } from './production-assets.ts'
7
+ import { productionSnapshot } from './production.ts'
8
+ import { createServedDeck, RouteRefusal } from './routes/served-deck.ts'
9
+ import { assertVoiceSource, VoiceSourceRefusal } from './voice-source.ts'
10
+
11
+ /** The workspace owns authoring and history; this service supplies real media and timing judgments. */
12
+ export function productionAudioAuthoring(ctx: NodeServiceContext): ProductionAudioAuthoring {
13
+ const served = createServedDeck({ ...ctx, service: token => ctx.service?.(token) })
14
+ return {
15
+ inspectBytes: inspectProductionAudioBytes,
16
+ async inspectAsset(asset, signal) {
17
+ return (await inspectProductionAsset({ productionAssets: { [asset.id]: asset } }, ctx.deckDir, asset.id, signal)).asset
18
+ },
19
+ async readPlan(query, signal) {
20
+ try {
21
+ const doc = latestDoc(ctx.deckDir)
22
+ if (!doc) throw new ProductionAudioRefusal(503, 'production-document-unavailable')
23
+ const revision = irRevision(doc)
24
+ if (query.irRevision !== undefined && query.irRevision !== revision) throw new ProductionAudioRefusal(409, 'production-revision-changed')
25
+ return (await productionSnapshot(served, { source: query.source, irRevision: revision }, signal)).plan
26
+ }
27
+ catch (error) {
28
+ if (error instanceof RouteRefusal) throw new ProductionAudioRefusal(error.status, error.message)
29
+ if (error instanceof VoiceSourceRefusal) throw new ProductionAudioRefusal(409, error.message)
30
+ throw error
31
+ }
32
+ },
33
+ async validateClips(binding, clips, assets, signal) {
34
+ try {
35
+ const snapshot = await productionSnapshot(served, { source: binding.source, irRevision: binding.irRevision }, signal)
36
+ if (snapshot.plan.binding.planId !== binding.planId) throw new ProductionAudioRefusal(409, 'production-plan-changed')
37
+ const doc = latestDoc(ctx.deckDir)
38
+ if (!doc || irRevision(doc) !== binding.irRevision) throw new ProductionAudioRefusal(409, 'production-revision-changed')
39
+ const candidate = { ...doc, production: { defaults: {}, stepOverrides: {}, ...doc.production, mediaClips: clips }, productionAssets: assets }
40
+ const inspected = await inspectProductionAssets(candidate, ctx.deckDir, signal)
41
+ const placed = placeProductionAudio(candidate, snapshot.plan.pages, new Map([...inspected.assets].map(([id, file]) => [id, file.asset])), inspected.errors)
42
+ if (placed.issues.length) throw new ProductionAudioRefusal(422, placed.issues[0]!.code)
43
+ await assertVoiceSource(doc, ctx.deckDir, binding.source)
44
+ if (latestDoc(ctx.deckDir) !== doc) throw new ProductionAudioRefusal(409, 'production-revision-changed')
45
+ signal?.throwIfAborted()
46
+ } catch (error) {
47
+ if (error instanceof RouteRefusal) throw new ProductionAudioRefusal(error.status, error.message)
48
+ if (error instanceof VoiceSourceRefusal) throw new ProductionAudioRefusal(409, error.message)
49
+ throw error
50
+ }
51
+ },
52
+ }
53
+ }
@@ -0,0 +1,93 @@
1
+ import { deckLocale, errorMessage, SPEECH } from '@lincyaw/kernel'
2
+ import type { Check, CheckContext, Finding, IRDocument, Severity } from '@lincyaw/kernel'
3
+ import { blockOf, clockText, eventDrift, eventLine, LECTURE_ID, LECTURE_MESSAGES, plannedUtterances, unchanged } from '../domain/index.ts'
4
+ import type { EventDrift } from '../domain/index.ts'
5
+ import { lectureView } from './latest-ir.ts'
6
+ import { deckPlan } from './plan.ts'
7
+ import { readTrack, trackIds } from './store.ts'
8
+ import { synthesizeClip, synthesizeLecture } from './synthesize.ts'
9
+
10
+ const SEVERITY: Record<EventDrift['grade'], Severity> = { exact: 'info', moved: 'info', fuzzy: 'warning', lost: 'warning' }
11
+
12
+ const HINTS: Partial<Record<EventDrift['grade'], string>> = {
13
+ lost: 'playback skips this event; give blocks a lecture refers to a label, or record the section again',
14
+ moved: 'playback follows the block to its new place',
15
+ }
16
+
17
+ function what(type: string, drift: EventDrift): string {
18
+ const target = drift.block ?? (typeof drift.data.section === 'string' ? `section ${drift.data.section}` : '')
19
+ switch (drift.reason) {
20
+ case 'section': return `${type} goes to ${target}, which the deck no longer has (lost)`
21
+ case 'steps': return `${type} goes to step ${String(drift.data.step)} of ${target}, which has fewer steps now`
22
+ case 'lines': return `${type} on ${target} names lines (${String(drift.data.lines ?? drift.data.line)}) the block no longer has (lost)`
23
+ case 'at': return `${type} is bound to utterance ${String((drift.data.at as unknown[])[0])}, which the track no longer has`
24
+ default:
25
+ if (drift.grade === 'moved') return `${type} on ${target}: the block moved to ${blockOf(drift.data)}`
26
+ if (drift.grade === 'fuzzy') return `${type} on ${target}: the block changed since the recording (fuzzy)`
27
+ return `${type} on ${target}, which the deck no longer has (lost)`
28
+ }
29
+ }
30
+
31
+ export function trackFindings(deckDir: string, id: string, doc: IRDocument): Finding[] {
32
+ let stored
33
+ try {
34
+ stored = readTrack(deckDir, id)
35
+ } catch (error) {
36
+ return [{ checkId: LECTURE_ID, severity: 'error', message: `lecture ${id}: ${errorMessage(error)}` }]
37
+ }
38
+ if (!stored) return []
39
+ const { track, text, file, problems } = stored
40
+ const findings: Finding[] = problems.map(message => ({ checkId: LECTURE_ID, severity: 'warning', message: `lecture ${id}: ${message}`, source: { file } }))
41
+ if (unchanged(track, doc)) return findings
42
+ for (const drift of eventDrift(track, doc)) {
43
+ if (drift.grade === 'exact') continue
44
+ const [t, type] = track.events[drift.index]!
45
+ findings.push({
46
+ checkId: LECTURE_ID,
47
+ severity: SEVERITY[drift.grade],
48
+ message: `lecture ${id} at ${clockText(t)} (${t} ms): ${what(type, drift)}`,
49
+ source: { file, line: eventLine(text, drift.index) },
50
+ ...HINTS[drift.grade] ? { hint: HINTS[drift.grade] } : {},
51
+ })
52
+ }
53
+ return findings
54
+ }
55
+
56
+ async function planFindings(ctx: CheckContext): Promise<Finding[]> {
57
+ const view = lectureView(ctx.ir)
58
+ const deck = deckPlan(view)
59
+ const findings = [...deck.findings]
60
+ const prepared = view.production?.mediaClips.length || Object.keys(view.production?.captionTimings ?? {}).length || deck.recorded ? await synthesizeLecture(view, ctx.deckDir, ctx.service?.(SPEECH), undefined, { cacheOnly: true }) : undefined
61
+ findings.push(...(prepared?.mediaDiagnostics ?? []).map(issue => ({ checkId: LECTURE_ID, severity: issue.severity, message: issue.message, source: { file: ctx.ir.meta.entry, line: 1 } })))
62
+ findings.push(...(prepared?.captionDiagnostics ?? []).map(issue => ({ checkId: LECTURE_ID, severity: issue.severity, message: issue.message, source: { file: ctx.ir.meta.entry, line: 1 } })))
63
+ if (!deck.voice) return findings
64
+ const speech = ctx.service?.(SPEECH)
65
+ const source = { file: ctx.ir.meta.entry, line: 1 }, t = LECTURE_MESSAGES[deckLocale(ctx.ir.meta.lang)].production
66
+ if (!speech) {
67
+ findings.push({ checkId: LECTURE_ID, severity: 'error', message: t.missingSpeech, source })
68
+ return findings
69
+ }
70
+ for (const u of plannedUtterances(deck.plan)) {
71
+ if (u.sourceKind === 'none' || u.sourceKind === 'recording') continue
72
+ try {
73
+ if (u.voiceIssue) throw new Error(u.voiceIssue)
74
+ if (prepared) { const failure = prepared.failures.find(failure => failure.id === u.id); if (failure) throw new Error(failure.reason) }
75
+ else await synthesizeClip(speech, u, { voice: u.voice ?? deck.voice, lang: deck.lang, ...deck.lexicon ? { lexicon: deck.lexicon } : {}, cacheOnly: true })
76
+ } catch (error) {
77
+ const text = u.text.length > 40 ? `${u.text.slice(0, 40)}…` : u.text
78
+ findings.push({
79
+ checkId: LECTURE_ID,
80
+ severity: 'error',
81
+ message: t.cacheMissing(text, u.section, t.reasons[errorMessage(error)] ?? t.reasonGeneric),
82
+ source: u.source ? { file: u.source.file, line: u.source.line } : source,
83
+ hint: t.generateHint,
84
+ })
85
+ }
86
+ }
87
+ return findings
88
+ }
89
+
90
+ export const lectureCheck: Check = {
91
+ id: LECTURE_ID,
92
+ run: async ctx => [...trackIds(ctx.deckDir).flatMap(id => trackFindings(ctx.deckDir, id, ctx.ir)), ...await planFindings(ctx)],
93
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, relative } from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { DECK_ENTRY } from '@lincyaw/compiler'
5
+ import { errorMessage } from '@lincyaw/kernel'
6
+ import { clockText } from '../domain/index.ts'
7
+ import { exportVideo } from './export.ts'
8
+
9
+ const USAGE = `Usage: presmium-lecture export [deckDir] [--track <id>] [--out <file.mp4|file.webm>] [--size <width>x<height>] [--fps <n>] [--burn <lang>]
10
+
11
+ Builds the deck's web form and lectures, plays the lecture in Chromium
12
+ (playwright-chromium) a frame at a time and writes it with its audio and a
13
+ subtitle track for each language of its captions, and the captions next to it
14
+ (<file>.<lang>.vtt). With FFmpeg (on the path, or PRESMIUM_FFMPEG) the video is
15
+ MP4 (H.264, AAC) or WebM (VP9, Opus) as --out names it; without it the browser
16
+ writes WebM only, at a lower quality, and the export says how to install it. The
17
+ animations follow the lecture's time frame by frame. --track names the
18
+ lecture (default: the synthesized one, else the only recorded one); --out the
19
+ file (default <deckDir>/dist/<track>.mp4, .webm without FFmpeg); --size the window (default
20
+ 1920x1080); --fps the frames a second (default 30); --burn draws the captions
21
+ in that language on the picture.`
22
+
23
+ function shown(file: string): string {
24
+ const rel = relative(process.cwd(), file)
25
+ return rel.startsWith('..') || isAbsolute(rel) ? file : rel
26
+ }
27
+
28
+ export async function main(argv: string[]): Promise<number> {
29
+ let args
30
+ try {
31
+ args = parseArgs({
32
+ args: argv,
33
+ allowPositionals: true,
34
+ options: { track: { type: 'string' }, out: { type: 'string', short: 'o' }, size: { type: 'string' }, fps: { type: 'string' }, burn: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
35
+ })
36
+ } catch (error) {
37
+ console.error(`${errorMessage(error)}\n\n${USAGE}`)
38
+ return 2
39
+ }
40
+ const { values, positionals } = args
41
+ if (values.help) {
42
+ console.log(USAGE)
43
+ return 0
44
+ }
45
+ const [command, deck = '.', ...rest] = positionals
46
+ if (command !== 'export' || rest.length > 0) {
47
+ console.error(USAGE)
48
+ return 2
49
+ }
50
+ const size = values.size === undefined ? undefined : /^(\d+)x(\d+)$/.exec(values.size)
51
+ const fps = values.fps === undefined ? undefined : Number(values.fps)
52
+ if (size === null || (fps !== undefined && !(fps > 0 && fps <= 60))) {
53
+ console.error(`--size takes <width>x<height> and --fps a number from 1 to 60\n\n${USAGE}`)
54
+ return 2
55
+ }
56
+ if (values.out && !/\.(mp4|webm)$/i.test(values.out)) {
57
+ console.error(`presmium-lecture export writes MP4 or WebM (--out <file>.mp4 or <file>.webm)`)
58
+ return 2
59
+ }
60
+ if (!existsSync(deck) || !statSync(deck).isDirectory() || !existsSync(join(deck, DECK_ENTRY))) {
61
+ console.error(`no ${DECK_ENTRY} in ${deck}\n\n${USAGE}`)
62
+ return 2
63
+ }
64
+ try {
65
+ const result = await exportVideo({
66
+ deckDir: deck,
67
+ ...values.track ? { track: values.track } : {},
68
+ ...values.out ? { out: values.out } : {},
69
+ ...size ? { width: Number(size[1]), height: Number(size[2]) } : {},
70
+ ...fps ? { fps } : {},
71
+ ...values.burn ? { burn: values.burn } : {},
72
+ log: line => console.error(line),
73
+ })
74
+ result.warnings.forEach(w => console.error(`warning: ${w}`))
75
+ console.log(`exported ${shown(result.file)} (lecture ${result.track}, ${clockText(result.duration)}, ${result.frames} frames)`)
76
+ for (const { file } of result.captions) console.log(`captions ${shown(file)}`)
77
+ return 0
78
+ } catch (error) {
79
+ console.error(`presmium-lecture: ${errorMessage(error)}`)
80
+ return 2
81
+ }
82
+ }
@@ -0,0 +1,10 @@
1
+ import { basename } from 'node:path'
2
+ import type { SpeechService, Synthesis } from '@lincyaw/kernel'
3
+ import { CLIP_FILE } from '../domain/index.ts'
4
+
5
+ export async function cachedClip(speech: SpeechService, name: string, signal?: AbortSignal): Promise<(Synthesis & { audio: string }) | undefined> {
6
+ if (!CLIP_FILE.test(name)) return undefined
7
+ if (!speech.cached) throw new Error('the speech service cannot look a clip up in its cache')
8
+ const found = await speech.cached(name.replace(/\.\w+$/, ''), signal)
9
+ return found && typeof found.audio === 'string' && basename(found.audio) === name ? { ...found, audio: found.audio } : undefined
10
+ }
@@ -0,0 +1,43 @@
1
+ import { isRecord, speechEngineId } from '@lincyaw/kernel'
2
+ import type { IRDocument, SpeechEngineInfo, SpeechService } from '@lincyaw/kernel'
3
+
4
+ export function sameEngine(a: string, b: string): boolean {
5
+ return speechEngineId(a) === speechEngineId(b)
6
+ }
7
+
8
+ function speechConfig(doc: IRDocument | undefined): Record<string, unknown> {
9
+ const config = doc?.config.presmium['presmium.speech']
10
+ return isRecord(config) ? config : {}
11
+ }
12
+
13
+ function setting(doc: IRDocument | undefined, key: 'transcribe' | 'engine'): string | undefined {
14
+ const value = speechConfig(doc)[key]
15
+ return typeof value === 'string' && value.length > 0 ? value : undefined
16
+ }
17
+
18
+ /**
19
+ * The engine that transcribes the document's recordings: the one presmium.speech.transcribe names, else
20
+ * its default engine (presmium.speech.engine) when that one transcribes, else the first that does.
21
+ */
22
+ export async function transcriptionEngine(speech: SpeechService, doc: IRDocument | undefined): Promise<SpeechEngineInfo | undefined> {
23
+ const engines = (await speech.engines()).filter(e => e.stt)
24
+ const named = setting(doc, 'transcribe')
25
+ if (named) return engines.find(e => sameEngine(e.id, named))
26
+ const preferred = setting(doc, 'engine')
27
+ return (preferred && engines.find(e => sameEngine(e.id, preferred))) || engines[0]
28
+ }
29
+
30
+ /** Why the document has no transcription engine. */
31
+ export function whyNoTranscriptionEngine(doc: IRDocument | undefined): string {
32
+ const named = setting(doc, 'transcribe')
33
+ return named
34
+ ? `presmium.speech.transcribe names speech engine "${named}", which is not defined or does not transcribe; name an engine that transcribes`
35
+ : 'no speech engine transcribes: the operator defines one (PRESMIUM_SPEECH_<ID>_URL and _PROVIDER)'
36
+ }
37
+
38
+ export async function synthesisEngine(speech: SpeechService, doc: IRDocument | undefined, engine?: string): Promise<SpeechEngineInfo | undefined> {
39
+ const config = speechConfig(doc)
40
+ const named = [engine, config.engine].find((v): v is string => typeof v === 'string' && v.length > 0)
41
+ const engines = (await speech.engines()).filter(e => e.tts)
42
+ return named ? engines.find(e => sameEngine(e.id, named)) : engines[0]
43
+ }