@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,112 @@
1
+ import type { TrackEvent, TranscriptSegment } from '@lincyaw/kernel'
2
+ import type { Clip } from './script-track.ts'
3
+ import { anchorOf } from './timing.ts'
4
+ import type { LectureTrack, MediaSegment, Utterance, Voice } from './track.ts'
5
+ import { charOffset, locate, realign, reanchor, trackWords } from './words.ts'
6
+
7
+ export interface SegmentTranscript {
8
+ segment: MediaSegment
9
+ segments: TranscriptSegment[]
10
+ }
11
+
12
+ function stepAt(track: LectureTrack, section: string, t: number): number {
13
+ let step = 0
14
+ for (const [at, type, data] of track.events) {
15
+ if (at > t) break
16
+ if (type === 'go' && data.section === section && typeof data.step === 'number') step = data.step
17
+ }
18
+ return step
19
+ }
20
+
21
+ function anchorAtTime(utterance: Utterance, t: number): number {
22
+ if (utterance.words?.length) {
23
+ const index = utterance.words.findIndex(([start, d]) => start + d > t)
24
+ return index === -1 ? utterance.words.length : index
25
+ }
26
+ const length = [...utterance.text].length
27
+ return Math.round(length * Math.max(0, Math.min(1, (t - utterance.t) / Math.max(1, utterance.d))))
28
+ }
29
+
30
+ export function bindEvents(track: LectureTrack): { track: LectureTrack, bound: number } {
31
+ const ids = new Set(track.utterances.map(u => u.id))
32
+ let bound = 0
33
+ const events = track.events.map(([t, type, data]): TrackEvent => {
34
+ const at = anchorOf(data)
35
+ if (at && ids.has(at[0])) return [t, type, data]
36
+ const { at: _stale, ...rest } = data
37
+ const utterance = track.utterances.find(u => u.t <= t && t < u.t + u.d)
38
+ if (!utterance) return [t, type, rest]
39
+ bound++
40
+ return [t, type, { ...rest, at: [utterance.id, anchorAtTime(utterance, t)] }]
41
+ })
42
+ return { track: { ...track, events }, bound }
43
+ }
44
+
45
+ export function transcribedTrack(track: LectureTrack, transcripts: readonly SegmentTranscript[]): { track: LectureTrack, bound: number } {
46
+ const said = transcripts
47
+ .flatMap(({ segment, segments }) => segments.filter(s => s.text.trim()).map(s => ({ segment, s })))
48
+ .sort((a, b) => (a.segment.start + a.s.start) - (b.segment.start + b.s.start))
49
+ const utterances: Utterance[] = said.map(({ segment, s }, i) => {
50
+ const t = Math.round((segment.start + s.start) * 1000)
51
+ const words = trackWords(s.words.map(w => ({ text: w.text, start: w.start - s.start, end: w.end - s.start })), t)
52
+ return {
53
+ id: `u${i + 1}`,
54
+ t,
55
+ d: Math.max(1, Math.round((s.end - s.start) * 1000)),
56
+ text: s.text.trim(),
57
+ ...words.length ? { words } : {},
58
+ alignment: words.length ? 'words' : 'none',
59
+ section: segment.section,
60
+ step: stepAt(track, segment.section, t),
61
+ }
62
+ })
63
+ return bindEvents({ ...track, utterances })
64
+ }
65
+
66
+ function moveAnchors(events: readonly TrackEvent[], id: string, anchor: (offset: number) => number): TrackEvent[] {
67
+ return events.map(([t, type, data]): TrackEvent => {
68
+ const at = anchorOf(data)
69
+ return at && at[0] === id ? [t, type, { ...data, at: [id, anchor(at[1])] }] : [t, type, data]
70
+ })
71
+ }
72
+
73
+ export function editUtterance(track: LectureTrack, id: string, text: string): LectureTrack | undefined {
74
+ const index = track.utterances.findIndex(u => u.id === id)
75
+ if (index === -1) return undefined
76
+ const { clip: _clip, ...before } = track.utterances[index]!
77
+ const { utterance, anchor } = realign(before, text.trim())
78
+ const utterances = track.utterances.map((u, i) => (i === index ? utterance : u))
79
+ return { ...track, utterances, events: moveAnchors(track.events, id, anchor) }
80
+ }
81
+
82
+ export function resynthesizedTrack(track: LectureTrack, clips: ReadonlyMap<string, Clip>, voice: Voice): LectureTrack {
83
+ let events = track.events
84
+ let end = 0
85
+ const utterances = [...track.utterances].sort((a, b) => a.t - b.t).map((said): Utterance => {
86
+ const clip = clips.get(said.id)
87
+ const t = Math.max(said.t, end)
88
+ const shift = t - said.t
89
+ const u = shift ? { ...said, t, ...said.words ? { words: said.words.map(([at, d, w]): [number, number, string] => [at + shift, d, w]) } : {} } : said
90
+ end = u.t + (clip ? Math.round(clip.duration * 1000) : u.d)
91
+ if (!clip) return u
92
+ const words = clip.words?.length ? trackWords(clip.words, u.t) : undefined
93
+ const before = u.words ?? []
94
+ let anchor: (offset: number) => number
95
+ if (words && before.length) {
96
+ anchor = reanchor(u.text, before, words)
97
+ } else if (words) {
98
+ const places = locate(words.map(w => w[2]), u.text)
99
+ anchor = (offset) => {
100
+ const index = places.findIndex(p => p !== undefined && charOffset(u.text, p.start) >= offset)
101
+ return index === -1 ? words.length : index
102
+ }
103
+ } else {
104
+ const places = locate(before.map(w => w[2]), u.text)
105
+ anchor = offset => (places[offset] ? charOffset(u.text, places[offset]!.start) : [...u.text].length)
106
+ }
107
+ events = moveAnchors(events, u.id, anchor)
108
+ const { words: _words, ...rest } = u
109
+ return { ...rest, d: Math.round(clip.duration * 1000), clip: clip.file, alignment: words?.length ? 'words' : 'proportional', ...words ? { words } : {} }
110
+ })
111
+ return { ...track, voice, utterances, events }
112
+ }
@@ -0,0 +1,13 @@
1
+ export function clockText(ms: number): string {
2
+ const total = Math.max(0, Math.floor(ms / 1000))
3
+ const s = String(total % 60).padStart(2, '0')
4
+ const m = Math.floor(total / 60) % 60
5
+ const h = Math.floor(total / 3600)
6
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
7
+ }
8
+
9
+ export function fileStem(section: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ const stem = section.replace(/[/\\?%*:|"<>#\u0000-\u001F\s]/g, '_').replace(/^\.+/, '_')
12
+ return stem.slice(0, 80) || 'section'
13
+ }
@@ -0,0 +1,50 @@
1
+ export { dropChangedSections, trimToDeck } from './audience.ts'
2
+ export { configuredLectureVoice, lectureGap, lectureVoice, lexiconOf, narrationVoice, prepareNarrationVoices } from './config.ts'
3
+ export type { LectureVoice } from './config.ts'
4
+ export { blockDrift, blockKey, blockOf, eventDrift, irHash, playedBlocks, referencedBlocks, sectionHashes, trackBlock, unchanged } from './drift.ts'
5
+ export type { BlockDrift, EventDrift, Grade } from './drift.ts'
6
+ export { bindEvents, editUtterance, resynthesizedTrack, transcribedTrack } from './editing.ts'
7
+ export type { SegmentTranscript } from './editing.ts'
8
+ export { clockText, fileStem } from './format.ts'
9
+ export { LECTURE_MESSAGES, LECTURE_TOOL_ERRORS } from './messages.ts'
10
+ export type { LectureMessages } from './messages.ts'
11
+ export { CUE_COMMANDS, cueProblem, lectureRole, parseCue, spokenText } from './narration.ts'
12
+ export type { CueCommand, CueMark, SpokenText } from './narration.ts'
13
+ export { lecturePlan, plannedAnimations, plannedUtterances, sectionHints } from './plan.ts'
14
+ export type { LecturePlan, PlannedAnimation, PlannedCue, PlannedParagraph, PlannedPiece, PlannedSection, PlanProblem } from './plan.ts'
15
+ export { recordedTrack, sectionSpan, simplifyPath, spliceSection } from './recording.ts'
16
+ export type { PathPoint, Recording, TrackHead } from './recording.ts'
17
+ export { scriptTrack } from './script-track.ts'
18
+ export type { Clip, CutAnimation, RecordingClip, ScriptHead } from './script-track.ts'
19
+ export { anchorOf, lastIndexAt, stepStops, textPieces, timedEvents, trackChapters, utteranceAt, wordTime } from './timing.ts'
20
+ export { captionGroups, captionGroupAt, trackCaptionGroups } from './captions.ts'
21
+ export type { CaptionGroup } from './captions.ts'
22
+ export type { StepStop, TextPiece, TimedEvent, TrackChapter } from './timing.ts'
23
+ export {
24
+ audioSegments,
25
+ CLIP_FILE,
26
+ defaultTrack,
27
+ CLIPS_DIR,
28
+ eventLine,
29
+ formatTrack,
30
+ LECTURE_ID,
31
+ LECTURE_RENDERER,
32
+ LECTURES_DIR,
33
+ parseTrack,
34
+ PLAYER_RENDERER,
35
+ SCRIPT_TRACK,
36
+ sortEvents,
37
+ spokenDuration,
38
+ TRACK_ID,
39
+ TRACK_VERSION,
40
+ trackDuration,
41
+ utteranceLine,
42
+ } from './track.ts'
43
+ export type { AudioSegment, LectureTrack, MediaSegment, TrackBlock, Translation, Utterance, VideoSegment, Voice, Word } from './track.ts'
44
+ export { isStale, translationDigest, translationItems } from './translation.ts'
45
+ export type { TranslationItem } from './translation.ts'
46
+ export { captionLanguages, captionsVtt, captionText, chaptersVtt, eventsVtt, vttTime } from './vtt.ts'
47
+ export type { Captions } from './vtt.ts'
48
+ export { charOffset, isWide, locate, realign, trackWords, wordIndexAt } from './words.ts'
49
+ export { cueFor, narrationDraft, narrationSource } from './writeback.ts'
50
+ export type { DraftSection, DraftStep, NarrationDraft, NarrationSource } from './writeback.ts'
@@ -0,0 +1,324 @@
1
+ import type { Locale } from '@lincyaw/kernel/contracts'
2
+
3
+ export interface LectureMessages {
4
+ view: string
5
+ production: {
6
+ missingSpeech: string
7
+ cacheMissing(text: string, section: string, reason: string): string
8
+ generateHint: string
9
+ incompleteCache: string
10
+ noVoice: string
11
+ noPreparedLecture: string
12
+ /** Why a sentence has no voiceover, by the code a speech service or the plan gives; others read as `reasonGeneric`. */
13
+ reasons: Record<string, string>
14
+ reasonGeneric: string
15
+ }
16
+ /** The problems of a {cue} mark, written into the document's diagnostics. */
17
+ cue: {
18
+ problem(command: string, reason: string): string
19
+ unknown(name: string, commands: readonly string[]): string
20
+ pauseDuration(arg: string): string
21
+ clickAction: string
22
+ markName: string
23
+ }
24
+ recorder: {
25
+ region: string
26
+ record: string
27
+ rerecord: string
28
+ stop: string
29
+ camera: string
30
+ screen: string
31
+ track: string
32
+ newTrack: string
33
+ recording: (time: string) => string
34
+ saving: string
35
+ saved: (id: string) => string
36
+ failed: (reason: string) => string
37
+ unsupported: string
38
+ laser: string
39
+ marker: string
40
+ clear: string
41
+ }
42
+ player: {
43
+ playbackTimeout: string
44
+ region: string
45
+ play: string
46
+ pause: string
47
+ seek: string
48
+ time: (now: string, total: string) => string
49
+ rate: string
50
+ more: string
51
+ presenter: string
52
+ previous: string
53
+ next: string
54
+ captions: string
55
+ transcript: string
56
+ chapters: string
57
+ sideTabs: string
58
+ language: string
59
+ close: string
60
+ synthetic: string
61
+ silent: string
62
+ syntheticBy: (provider: string) => string
63
+ loading: string
64
+ failed: (reason: string) => string
65
+ missing: (id: string) => string
66
+ dropped: (count: number) => string
67
+ unsaid: (count: number) => string
68
+ stale: string
69
+ jump: (time: string) => string
70
+ step: (step: number) => string
71
+ }
72
+ list: {
73
+ title: string
74
+ empty: string
75
+ unavailable: string
76
+ open: (id: string) => string
77
+ }
78
+ editor: {
79
+ region: string
80
+ transcribe: (provider: string) => string
81
+ transcribeConfirm: (id: string, provider: string, local: boolean) => string
82
+ transcribing: string
83
+ transcribed: (count: number) => string
84
+ voice: string
85
+ synthesize: (provider: string) => string
86
+ synthesizeConfirm: (provider: string, voice: string, count: number, local: boolean) => string
87
+ synthesizing: string
88
+ synthesized: string
89
+ local: string
90
+ confirm: string
91
+ cancel: string
92
+ edit: (id: string) => string
93
+ save: string
94
+ saveSay: (provider: string) => string
95
+ saveSayConfirm: (provider: string, local: boolean) => string
96
+ saving: string
97
+ failed: (reason: string) => string
98
+ }
99
+ }
100
+
101
+ /** What the lecture's tools say to the AI and the author for the codes they fail with (the AI may pass it on). */
102
+ export const LECTURE_TOOL_ERRORS: Record<Locale, Record<string, string>> = {
103
+ 'zh-CN': {
104
+ 'lecture-plugin-missing': '这份文稿没有启用讲授插件。',
105
+ 'document-not-ready': '文稿还没有准备好,请稍后重试。',
106
+ 'speech-unavailable': '未连接语音服务。请在文稿中启用语音服务插件 addon-speech。',
107
+ 'track-required': '需要给出讲授的名称(track)。',
108
+ 'lang-required': '需要给出要译成的语言(lang)。',
109
+ },
110
+ en: {
111
+ 'lecture-plugin-missing': 'This document doesn’t use the lecture plugin.',
112
+ 'document-not-ready': 'The document isn’t ready yet. Try again shortly.',
113
+ 'speech-unavailable': 'No voice service is connected. Enable the voice service plugin addon-speech in the document.',
114
+ 'track-required': 'Give the name of the lecture (track).',
115
+ 'lang-required': 'Give the language to translate into (lang).',
116
+ },
117
+ }
118
+
119
+ export const LECTURE_MESSAGES: Record<Locale, LectureMessages> = {
120
+ 'zh-CN': {
121
+ view: '讲授',
122
+ production: {
123
+ missingSpeech: '读取已生成的配音需要语音服务。请在文稿的 metadata.plugins 中启用语音服务插件 addon-speech。',
124
+ cacheMissing: (text, section, reason) => `页面 ${section} 的讲稿“${text}”没有与当前文字和音色一致的配音。${reason}`,
125
+ generateHint: '请先为当前讲稿和音色生成配音,再重新导出。检查和导出不会自动生成缺失的配音。',
126
+ incompleteCache: '当前讲稿与音色的配音不完整。请先生成配音,再导出。',
127
+ noVoice: '视频制作设置未指定音色,文稿没有生成的配音。',
128
+ noPreparedLecture: '文稿还没有可用的配音。请提供录音,或选择音色并生成配音。',
129
+ reasons: {
130
+ 'voiceover-not-generated': '还没有为当前讲稿和音色生成配音。',
131
+ 'voiceover-cache-missing': '语音服务没有给出已生成配音的文件。',
132
+ 'speech-unavailable': '未连接语音服务。',
133
+ 'voice-unset': '没有选择音色。',
134
+ 'voice-step-binding-unavailable': '这句讲稿所在的步骤已经变化,请重新选择音色。',
135
+ 'production-media-changed': '配音文件在读取时发生了变化,请重试。',
136
+ },
137
+ reasonGeneric: '无法读取这句讲稿的配音。',
138
+ },
139
+ cue: {
140
+ problem: (command, reason) => `提示“${command}”:${reason}`,
141
+ unknown: (name, commands) => `没有“${name}”这种提示。可用的提示:${commands.join('、')}。`,
142
+ pauseDuration: arg => `停顿需要时长,例如“pause 800ms”,而不是“${arg}”。`,
143
+ clickAction: '点击需要组件的操作名,例如“click reset”。',
144
+ markName: '标记需要名称,例如“mark merge”。',
145
+ },
146
+ recorder: {
147
+ region: '讲授录制',
148
+ record: '录制',
149
+ rerecord: '重录本页',
150
+ stop: '停止',
151
+ camera: '摄像头',
152
+ screen: '屏幕',
153
+ track: '讲授',
154
+ newTrack: '新建讲授',
155
+ recording: time => `正在录制 ${time}`,
156
+ saving: '正在保存…',
157
+ saved: id => `已保存 ${id}`,
158
+ failed: reason => `录制失败:${reason}`,
159
+ unsupported: '此浏览器无法录音',
160
+ laser: '激光笔',
161
+ marker: '荧光笔',
162
+ clear: '清除标记',
163
+ },
164
+ player: {
165
+ playbackTimeout: '无法开始播放。请重试。',
166
+ region: '讲授回放',
167
+ play: '播放',
168
+ pause: '暂停',
169
+ seek: '进度',
170
+ time: (now, total) => `${now} / ${total}`,
171
+ rate: '倍速',
172
+ more: '更多播放选项',
173
+ presenter: '演示者视图',
174
+ previous: '上一步',
175
+ next: '下一步',
176
+ captions: '字幕',
177
+ transcript: '字幕列表',
178
+ chapters: '章节',
179
+ sideTabs: '字幕列表与章节',
180
+ language: '字幕语言',
181
+ close: '关闭讲授',
182
+ synthetic: 'AI 生成的配音',
183
+ silent: '无声播放',
184
+ syntheticBy: provider => `AI 生成的配音(${provider})`,
185
+ loading: '正在加载讲授…',
186
+ failed: reason => `无法加载讲授:${reason}`,
187
+ missing: id => `找不到讲授 ${id}`,
188
+ dropped: count => `文稿改动后有 ${count} 个操作失效,回放时略过`,
189
+ unsaid: count => `有 ${count} 句尚未生成配音,按估计的时长无声播放并显示字幕`,
190
+ stale: '译文需要更新:原句在翻译之后有改动',
191
+ jump: time => `跳到 ${time}`,
192
+ step: step => `第 ${step} 步`,
193
+ },
194
+ list: {
195
+ title: '讲授',
196
+ empty: '这份文稿还没有讲授',
197
+ unavailable: '无法读取讲授列表',
198
+ open: id => `播放讲授 ${id}`,
199
+ },
200
+ editor: {
201
+ region: '讲授的讲稿与配音',
202
+ transcribe: provider => `转写(${provider})`,
203
+ transcribeConfirm: (id, provider, local) => (local ? `用 ${provider} 转写讲授 ${id} 的录音?` : `把讲授 ${id} 的录音发给 ${provider} 转写?转写由该服务计费。`),
204
+ transcribing: '正在转写…',
205
+ transcribed: count => `已转写 ${count} 句`,
206
+ voice: '音色',
207
+ synthesize: provider => `重新生成配音(${provider})`,
208
+ synthesizeConfirm: (provider, voice, count, local) => `用 ${provider} 的音色“${voice}”为这段讲授的 ${count} 句话重新生成配音?${local ? '' : '生成由该服务计费,'}结果由 AI 生成。`,
209
+ synthesizing: '正在生成…',
210
+ synthesized: '已重新生成',
211
+ local: '本机,不外发',
212
+ confirm: '确定',
213
+ cancel: '取消',
214
+ edit: id => `编辑语句 ${id}`,
215
+ save: '保存',
216
+ saveSay: provider => `保存并重新生成配音(${provider})`,
217
+ saveSayConfirm: (provider, local) => (local ? `用 ${provider} 为改过的这句话重新生成配音?结果由 AI 生成。` : `把改过的这句话发给 ${provider} 重新生成配音?生成由该服务计费,结果由 AI 生成。`),
218
+ saving: '正在保存…',
219
+ failed: reason => `无法完成:${reason}`,
220
+ },
221
+ },
222
+ 'en': {
223
+ view: 'Lecture',
224
+ production: {
225
+ missingSpeech: 'Reading the generated voiceover needs the voice service. Enable the voice service plugin addon-speech in the document’s metadata.plugins.',
226
+ cacheMissing: (text, section, reason) => `The script “${text}” on page ${section} has no voiceover that matches its current text and voice. ${reason}`,
227
+ generateHint: 'Generate the voiceover for the current script and voice first, then export again. Checking and exporting never generate missing voiceover.',
228
+ incompleteCache: 'The voiceover for the current script and voice is incomplete. Generate the voiceover before exporting.',
229
+ noVoice: 'The video production settings name no voice, so the document has no generated voiceover.',
230
+ noPreparedLecture: 'The document has no voiceover yet. Provide a recording, or choose a voice and generate the voiceover first.',
231
+ reasons: {
232
+ 'voiceover-not-generated': 'No voiceover is generated for the current script and voice yet.',
233
+ 'voiceover-cache-missing': 'The voice service gave no file for the generated voiceover.',
234
+ 'speech-unavailable': 'No voice service is connected.',
235
+ 'voice-unset': 'No voice is chosen.',
236
+ 'voice-step-binding-unavailable': 'The step of this sentence changed. Choose its voice again.',
237
+ 'production-media-changed': 'The voiceover file changed while it was read. Try again.',
238
+ },
239
+ reasonGeneric: 'Couldn’t read the voiceover of this sentence.',
240
+ },
241
+ cue: {
242
+ problem: (command, reason) => `Cue “${command}”: ${reason}`,
243
+ unknown: (name, commands) => `There is no cue “${name}”. The cues are ${commands.join(', ')}.`,
244
+ pauseDuration: arg => `A pause needs a duration, such as “pause 800ms”, not “${arg}”.`,
245
+ clickAction: 'A click needs the action of the component, such as “click reset”.',
246
+ markName: 'A mark needs a name, such as “mark merge”.',
247
+ },
248
+ recorder: {
249
+ region: 'Lecture Recording',
250
+ record: 'Record',
251
+ rerecord: 'Re-record Page',
252
+ stop: 'Stop',
253
+ camera: 'Camera',
254
+ screen: 'Screen',
255
+ track: 'Lecture',
256
+ newTrack: 'New Lecture',
257
+ recording: time => `Recording ${time}`,
258
+ saving: 'Saving…',
259
+ saved: id => `Saved ${id}`,
260
+ failed: reason => `Recording failed: ${reason}`,
261
+ unsupported: 'This browser can’t record audio',
262
+ laser: 'Laser',
263
+ marker: 'Marker',
264
+ clear: 'Clear Marks',
265
+ },
266
+ player: {
267
+ playbackTimeout: 'Couldn’t start playback. Please try again.',
268
+ region: 'Lecture Playback',
269
+ play: 'Play',
270
+ pause: 'Pause',
271
+ seek: 'Position',
272
+ time: (now, total) => `${now} / ${total}`,
273
+ rate: 'Speed',
274
+ more: 'More Playback Options',
275
+ presenter: 'Presenter View',
276
+ previous: 'Previous Step',
277
+ next: 'Next Step',
278
+ captions: 'Captions',
279
+ transcript: 'Caption List',
280
+ chapters: 'Chapters',
281
+ sideTabs: 'Caption list and chapters',
282
+ language: 'Caption language',
283
+ close: 'Close Lecture',
284
+ synthetic: 'AI-generated voiceover',
285
+ silent: 'Silent playback',
286
+ syntheticBy: provider => `AI-generated voiceover (${provider})`,
287
+ loading: 'Loading the lecture…',
288
+ failed: reason => `Couldn’t load the lecture: ${reason}`,
289
+ missing: id => `Lecture ${id} not found`,
290
+ dropped: count => `${count} actions no longer match the document and are skipped`,
291
+ unsaid: count => `${count} sentences have no voiceover yet; they play silently for their estimated length, with captions`,
292
+ stale: 'Translation update needed: the sentence changed after it was translated',
293
+ jump: time => `Go to ${time}`,
294
+ step: step => `Step ${step}`,
295
+ },
296
+ list: {
297
+ title: 'Lectures',
298
+ empty: 'This document has no lecture yet',
299
+ unavailable: 'Couldn’t read the lectures',
300
+ open: id => `Play Lecture ${id}`,
301
+ },
302
+ editor: {
303
+ region: 'Lecture Script and Voiceover',
304
+ transcribe: provider => `Transcribe (${provider})`,
305
+ transcribeConfirm: (id, provider, local) => (local ? `Transcribe the audio of lecture ${id} with ${provider}?` : `Send the audio of lecture ${id} to ${provider} for transcription? The service charges for it.`),
306
+ transcribing: 'Transcribing…',
307
+ transcribed: count => `Transcribed ${count} sentences`,
308
+ voice: 'Voice',
309
+ synthesize: provider => `Regenerate Voiceover (${provider})`,
310
+ synthesizeConfirm: (provider, voice, count, local) => `Regenerate the voiceover for the ${count} sentences of this lecture in the voice “${voice}” of ${provider}? ${local ? 'The' : 'The service charges for it; the'} result is AI-generated.`,
311
+ synthesizing: 'Generating…',
312
+ synthesized: 'Regenerated',
313
+ local: 'On this machine; nothing is sent',
314
+ confirm: 'OK',
315
+ cancel: 'Cancel',
316
+ edit: id => `Edit Sentence ${id}`,
317
+ save: 'Save',
318
+ saveSay: provider => `Save and Regenerate Voiceover (${provider})`,
319
+ saveSayConfirm: (provider, local) => (local ? `Regenerate the voiceover for the corrected sentence with ${provider}? The result is AI-generated.` : `Send the corrected sentence to ${provider} to regenerate its voiceover? The service charges for it; the result is AI-generated.`),
320
+ saving: 'Saving…',
321
+ failed: reason => `Couldn’t finish: ${reason}`,
322
+ },
323
+ },
324
+ }
@@ -0,0 +1,160 @@
1
+ import { deckLocale, isRecord, parseDuration } from '@lincyaw/kernel'
2
+ import type { MdastNode, WordSpan } from '@lincyaw/kernel'
3
+ import { LECTURE_MESSAGES } from './messages.ts'
4
+ import { LECTURE_ID } from './track.ts'
5
+ import { isWide } from './words.ts'
6
+
7
+ export interface CueMark {
8
+ offset: number
9
+ command: string
10
+ to?: string
11
+ lines?: string
12
+ sel?: string
13
+ }
14
+
15
+ export interface SpokenText {
16
+ text: string
17
+ spoken: string
18
+ spans: WordSpan[]
19
+ cues: CueMark[]
20
+ }
21
+
22
+ const SILENT = new Set(['image', 'imageReference', 'html', 'footnoteReference', 'definition', 'footnoteDefinition', 'comment', 'mystComment', 'target', 'mystTarget'])
23
+
24
+ const BLOCKS = new Set(['paragraph', 'listItem', 'heading', 'blockquote', 'tableCell', 'tableRow'])
25
+
26
+ function text(value: unknown): string | undefined {
27
+ return typeof value === 'string' && value ? value : undefined
28
+ }
29
+
30
+ class Builder {
31
+ text = ''
32
+ spoken = ''
33
+ readonly spans: WordSpan[] = []
34
+ readonly cues: CueMark[] = []
35
+ private pending: 'space' | 'line' | undefined
36
+
37
+ append(shown: string, spoken = shown): void {
38
+ if (shown === spoken) {
39
+ for (const char of shown) this.char(char, char)
40
+ return
41
+ }
42
+ const [lead] = /^\s*/.exec(shown)!
43
+ for (const char of lead) this.char(char, char)
44
+ this.flush(shown.trim()[0] ?? '')
45
+ const start = this.text.length
46
+ const from = this.spoken.length
47
+ this.text += collapse(shown)
48
+ this.spoken += collapse(spoken)
49
+ this.spans.push({ from, to: this.spoken.length, start, end: this.text.length })
50
+ if (/\s$/.test(shown)) this.pending = 'space'
51
+ }
52
+
53
+ cue(mark: Omit<CueMark, 'offset'>): void {
54
+ this.cues.push({ ...mark, offset: this.text.length })
55
+ }
56
+
57
+ line(): void {
58
+ if (this.text) this.pending = 'line'
59
+ }
60
+
61
+ done(): SpokenText {
62
+ return { text: this.text, spoken: this.spoken, spans: this.spans, cues: this.cues.map(c => ({ ...c, offset: Math.min(c.offset, this.text.length) })) }
63
+ }
64
+
65
+ private char(shown: string, spoken: string): void {
66
+ if (/\s/.test(shown)) {
67
+ if (this.text) this.pending = shown === '\n' || this.pending === 'line' ? 'line' : 'space'
68
+ return
69
+ }
70
+ this.flush(shown)
71
+ this.text += shown
72
+ this.spoken += spoken
73
+ }
74
+
75
+ private flush(next: string): void {
76
+ const pending = this.pending
77
+ this.pending = undefined
78
+ if (!pending || !this.text) return
79
+ if (isWide(this.text.at(-1)) && (isWide(next) || !next)) return
80
+ this.text += ' '
81
+ this.spoken += ' '
82
+ }
83
+ }
84
+
85
+ function collapse(value: string): string {
86
+ return value.replace(/\s+/g, ' ').trim()
87
+ }
88
+
89
+ export function lectureRole(node: MdastNode, name: 'cue' | 'say'): boolean {
90
+ return node.type === 'presmiumRole' && node.plugin === LECTURE_ID && node.name === name
91
+ }
92
+
93
+ function walk(node: MdastNode, out: Builder): void {
94
+ if (SILENT.has(node.type)) return
95
+ if (node.type === 'presmiumRole') {
96
+ const options = isRecord(node.options) ? node.options : {}
97
+ if (lectureRole(node, 'cue')) {
98
+ out.cue({
99
+ command: (text(options.value) ?? '').trim(),
100
+ ...text(options.to) ? { to: text(options.to) } : {},
101
+ ...options.lines !== undefined && options.lines !== '' ? { lines: String(options.lines) } : {},
102
+ ...text(options.sel) ? { sel: text(options.sel) } : {},
103
+ })
104
+ return
105
+ }
106
+ if (lectureRole(node, 'say')) {
107
+ const shown = (node.children ?? []).map(child => plain(child)).join('') || (text(options.value) ?? '')
108
+ out.append(shown, text(options.alias) ?? shown)
109
+ return
110
+ }
111
+ }
112
+ if (node.type === 'break') {
113
+ out.line()
114
+ return
115
+ }
116
+ if (typeof node.value === 'string' && !node.children) {
117
+ out.append(node.value)
118
+ return
119
+ }
120
+ const block = BLOCKS.has(node.type)
121
+ if (block) out.line()
122
+ for (const child of node.children ?? []) walk(child, out)
123
+ if (block) out.line()
124
+ }
125
+
126
+ function plain(node: MdastNode): string {
127
+ if (SILENT.has(node.type)) return ''
128
+ if (typeof node.value === 'string' && !node.children) return node.value
129
+ return (node.children ?? []).map(plain).join('')
130
+ }
131
+
132
+ export function spokenText(node: MdastNode): SpokenText {
133
+ const out = new Builder()
134
+ walk(node, out)
135
+ return out.done()
136
+ }
137
+
138
+ export type CueCommand = 'next' | 'spot' | 'hint' | 'point' | 'click' | 'run' | 'ink' | 'pause' | 'mark'
139
+
140
+ export const CUE_COMMANDS: readonly CueCommand[] = ['next', 'spot', 'hint', 'point', 'click', 'run', 'ink', 'pause', 'mark']
141
+
142
+ export const TARGETED: ReadonlySet<CueCommand> = new Set(['spot', 'hint', 'point', 'click', 'run', 'ink'])
143
+
144
+ /** What is wrong with a {cue} command, as a code; `cueProblem` words it in a document's language. */
145
+ export type CueProblem = { code: 'unknown', name: string } | { code: 'pause-duration', arg: string } | { code: 'click-action' } | { code: 'mark-name' }
146
+
147
+ export function parseCue(command: string): { name: CueCommand, arg: string } | { problem: CueProblem } {
148
+ const [name = '', ...rest] = command.trim().split(/\s+/)
149
+ const arg = rest.join(' ')
150
+ if (!(CUE_COMMANDS as readonly string[]).includes(name)) return { problem: { code: 'unknown', name } }
151
+ if (name === 'pause' && parseDuration(arg) === undefined) return { problem: { code: 'pause-duration', arg } }
152
+ if ((name === 'click' || name === 'mark') && !arg) return { problem: { code: name === 'click' ? 'click-action' : 'mark-name' } }
153
+ return { name: name as CueCommand, arg }
154
+ }
155
+
156
+ export function cueProblem(command: string, problem: CueProblem, lang: string | undefined): string {
157
+ const t = LECTURE_MESSAGES[deckLocale(lang)].cue
158
+ const reason = problem.code === 'unknown' ? t.unknown(problem.name, CUE_COMMANDS) : problem.code === 'pause-duration' ? t.pauseDuration(problem.arg) : problem.code === 'click-action' ? t.clickAction : t.markName
159
+ return t.problem(command, reason)
160
+ }