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

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 +153 -0
  10. package/src/client/PlayerPanel.tsx +198 -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 +179 -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,176 @@
1
+ import type { SourcePos, TimeRange, TrackEvent, WrittenWord } from '@lincyaw/kernel'
2
+ import type { LecturePlan, PlannedCue } from './plan.ts'
3
+ import { wordTime } from './timing.ts'
4
+ import type { LectureTrack, MediaSegment, TrackBlock, Utterance, Voice } from './track.ts'
5
+ import { sortEvents, spokenDuration, TRACK_VERSION } from './track.ts'
6
+ import { charOffset, trackWords, wordIndexAt } from './words.ts'
7
+
8
+ export interface Clip {
9
+ file: string
10
+ duration: number
11
+ words?: WrittenWord[]
12
+ speech?: Voice
13
+ sha256?: string
14
+ }
15
+
16
+ export interface RecordingClip {
17
+ assetId: string
18
+ clipId: string
19
+ src: string
20
+ sha256: string
21
+ durationMs: number
22
+ sourceRange?: TimeRange
23
+ }
24
+
25
+ export interface ScriptHead {
26
+ id: string
27
+ doc: string
28
+ lang: string
29
+ ir: LectureTrack['ir']
30
+ voice: Voice
31
+ gap: number
32
+ }
33
+
34
+ /** A `{cue}next` that moved on before the animation of its step had played. */
35
+ export interface CutAnimation {
36
+ section: string
37
+ /** The step whose animation the cue cut short (as the plan counts steps). */
38
+ step: number
39
+ /** How much of the animation was left (ms). */
40
+ left: number
41
+ source?: SourcePos
42
+ }
43
+
44
+ function anchorAt(utterance: Utterance, words: readonly WrittenWord[] | undefined, offset: number): number {
45
+ return words?.length ? wordIndexAt(words, offset, utterance.text.length) : charOffset(utterance.text, offset)
46
+ }
47
+
48
+ /**
49
+ * The synthesized lecture's timeline. A step lasts as long as its longest animation, except where the
50
+ * narration says `{cue}next` before then: the cue keeps the word it is bound to, and `cuts` list it.
51
+ */
52
+ /**
53
+ * The lecture of `plan` said with `clips`. `unsaid`: the sentences without a clip, by id, with the reason; each plays
54
+ * silent for the time it would take to say, so that a preview keeps the lecture's timeline.
55
+ */
56
+ export function scriptTrack(plan: LecturePlan, clips: ReadonlyMap<string, Clip>, head: ScriptHead, blocks: (events: TrackEvent[]) => Record<string, TrackBlock>, unsaid: ReadonlyMap<string, string> = new Map(), silent: ReadonlySet<string> = new Set(), recordings: ReadonlyMap<string, RecordingClip> = new Map()): { track: LectureTrack, cuts: CutAnimation[] } {
57
+ const cuts: CutAnimation[] = []
58
+ const events: TrackEvent[] = []
59
+ const utterances: Utterance[] = []
60
+ const audio: MediaSegment[] = []
61
+ let t = 0
62
+ let inked = false
63
+ for (const section of plan.sections) {
64
+ let current = 0
65
+ let since = t
66
+ const go = (at: number, step: number) => events.push([at, 'go', { section: section.id, step: section.webStep(step) }])
67
+ // A step lasts as long as its longest animation: the lecture waits for it before it moves on.
68
+ const hold = () => {
69
+ const until = since + (section.holds[current] ?? 0)
70
+ if (until <= t) return
71
+ events.push([t, 'pause', { ms: until - t, hold: true }])
72
+ t = until
73
+ }
74
+ go(t, 0)
75
+ if (inked) events.push([t, 'ink', { clear: true }])
76
+ inked = false
77
+ for (let step = 0; step <= section.steps; step++) {
78
+ if (step > current) {
79
+ hold()
80
+ go(t, step)
81
+ current = step
82
+ since = t
83
+ }
84
+ const paragraphs = section.paragraphs.filter(p => p.step === step)
85
+ const recording = section.recordings?.find(recording => recording.step === step)
86
+ if (recording) {
87
+ const clip = recordings.get(recording.id)
88
+ if (clip && !recording.issue) {
89
+ if (recording.mode === 'after') hold()
90
+ audio.push({ section: section.id, role: 'recording', stepId: recording.stepId, narrationIds: recording.narrationIds, start: t / 1000, duration: clip.durationMs / 1000, sourceStartMs: clip.sourceRange?.startMs ?? 0, src: clip.src, clipId: clip.clipId, assetId: clip.assetId, sha256: clip.sha256 })
91
+ if (recording.text) utterances.push({ id: recording.id, t, d: clip.durationMs, text: recording.text, section: section.id, step: section.webStep(step), narrationIds: recording.narrationIds, recording: true, voice: { kind: 'recorded' } })
92
+ for (const cue of recording.cues) { if (cue.name === 'ink') inked = true; events.push([t, cue.name, cue.data]) }
93
+ t += clip.durationMs
94
+ }
95
+ if (!section.trailingPauseMs && paragraphs.length) t += head.gap
96
+ } else for (const paragraph of paragraphs) {
97
+ if (paragraph.mode === 'after') hold()
98
+ for (const piece of paragraph.pieces) {
99
+ const clip = piece.id ? clips.get(piece.id) : undefined
100
+ let utterance: Utterance | undefined
101
+ if (piece.id && clip) {
102
+ utterance = { id: piece.id, t, d: Math.round(clip.duration * 1000), text: piece.text, section: section.id, step: section.webStep(current), clip: clip.file }
103
+ if (clip.speech) utterance.voice = clip.speech
104
+ if (clip.sha256) utterance.audioSha256 = clip.sha256
105
+ if (clip.words?.length) { utterance.words = trackWords(clip.words, t); utterance.alignment = 'words' }
106
+ else utterance.alignment = 'proportional'
107
+ utterances.push(utterance)
108
+ } else if (piece.id && silent.has(piece.id)) {
109
+ utterance = { id: piece.id, t, d: 0, text: piece.text, section: section.id, step: section.webStep(current), silent: true }
110
+ utterances.push(utterance)
111
+ } else if (piece.id && unsaid.has(piece.id)) {
112
+ utterance = { id: piece.id, t, d: spokenDuration(piece.text, head.lang), text: piece.text, section: section.id, step: section.webStep(current), unsaid: unsaid.get(piece.id)! }
113
+ utterances.push(utterance)
114
+ }
115
+ if (utterance && paragraph.narrationId) utterance.narrationIds = [paragraph.narrationId]
116
+ const cue = (c: PlannedCue, data: Record<string, unknown>): number => {
117
+ if (c.name === 'ink') inked = true
118
+ if (!utterance) {
119
+ events.push([t, c.name === 'next' ? 'go' : c.name, data])
120
+ return t
121
+ }
122
+ const at = anchorAt(utterance, clip?.words, c.offset)
123
+ const time = wordTime(utterance, at)
124
+ events.push([time, c.name === 'next' ? 'go' : c.name, { ...data, at: [utterance.id, at] }])
125
+ return time
126
+ }
127
+ for (const c of piece.cues) {
128
+ if (c.name === 'next') {
129
+ if (current >= section.steps) continue
130
+ const end = since + (section.holds[current] ?? 0)
131
+ current++
132
+ since = cue(c, { section: section.id, step: section.webStep(current) })
133
+ if (since < end) cuts.push({ section: section.id, step: current - 1, left: end - since, ...paragraph.source ? { source: paragraph.source } : {} })
134
+ } else {
135
+ cue(c, c.data)
136
+ }
137
+ }
138
+ if (utterance) t += utterance.d
139
+ if (piece.pause) t += piece.pause
140
+ }
141
+ if (!section.trailingPauseMs) t += head.gap
142
+ }
143
+ if (section.trailingPauseMs) { hold(); t += section.trailingPauseMs[step] ?? head.gap }
144
+ else if (paragraphs.length === 0) t += head.gap
145
+ }
146
+ hold()
147
+ }
148
+ const ordered = sortEvents(events)
149
+ const combined: Utterance[] = []
150
+ for (const utterance of utterances) {
151
+ const previous = combined.at(-1)
152
+ if (utterance.silent && previous?.silent && previous.t === utterance.t && previous.section === utterance.section && previous.step === utterance.step) {
153
+ previous.text += `\n\n${utterance.text}`
154
+ previous.narrationIds = [...new Set([...(previous.narrationIds ?? []), ...(utterance.narrationIds ?? [])])]
155
+ } else combined.push(utterance)
156
+ }
157
+ for (const [index, utterance] of combined.entries()) if (utterance.silent) {
158
+ const boundary = ordered.find(([at, type, data]) => type === 'go' && at > utterance.t && (data.section !== utterance.section || Number(data.step ?? 0) !== utterance.step))?.[0] ?? t
159
+ const next = combined[index + 1]?.t ?? t
160
+ utterance.d = Math.max(0, Math.min(boundary, next) - utterance.t)
161
+ }
162
+ const track: LectureTrack = {
163
+ track: TRACK_VERSION,
164
+ id: head.id,
165
+ doc: head.doc,
166
+ lang: head.lang,
167
+ ir: head.ir,
168
+ voice: head.voice,
169
+ blocks: blocks(events),
170
+ durationMs: t,
171
+ utterances: combined,
172
+ ...audio.length ? { audio } : {},
173
+ events: ordered,
174
+ }
175
+ return { track, cuts }
176
+ }
@@ -0,0 +1,107 @@
1
+ import type { LectureTrack, Utterance } from './track.ts'
2
+
3
+ export interface TimedEvent {
4
+ t: number
5
+ type: string
6
+ data: Record<string, unknown>
7
+ index: number
8
+ }
9
+
10
+ export function wordTime(utterance: Utterance, offset: number): number {
11
+ const words = utterance.words
12
+ if (words?.length) {
13
+ if (offset >= words.length) return words.at(-1)![0] + words.at(-1)![1]
14
+ return words[Math.max(0, offset)]![0]
15
+ }
16
+ const length = Math.max(1, [...utterance.text].length)
17
+ return utterance.t + Math.round(utterance.d * Math.max(0, Math.min(offset, length)) / length)
18
+ }
19
+
20
+ export function anchorOf(data: Record<string, unknown>): [string, number] | undefined {
21
+ const at = data.at
22
+ return Array.isArray(at) && typeof at[0] === 'string' && typeof at[1] === 'number' ? [at[0], at[1]] : undefined
23
+ }
24
+
25
+ export function timedEvents(track: LectureTrack): TimedEvent[] {
26
+ const utterances = new Map(track.utterances.map(u => [u.id, u]))
27
+ return track.events
28
+ .map(([t, type, data], index): TimedEvent => {
29
+ const at = anchorOf(data)
30
+ const utterance = at && utterances.get(at[0])
31
+ return { t: utterance ? wordTime(utterance, at![1]) : t, type, data, index }
32
+ })
33
+ .sort((a, b) => a.t - b.t || a.index - b.index)
34
+ }
35
+
36
+ export interface TextPiece {
37
+ text: string
38
+ t?: number
39
+ }
40
+
41
+ export function textPieces(utterance: Utterance): TextPiece[] | undefined {
42
+ if (!utterance.words?.length) return undefined
43
+ const pieces: TextPiece[] = []
44
+ let at = 0
45
+ for (const [t, , word] of utterance.words) {
46
+ const index = word ? utterance.text.indexOf(word, at) : -1
47
+ if (index < 0) return undefined
48
+ if (index > at) pieces.push({ text: utterance.text.slice(at, index) })
49
+ pieces.push({ text: word, t })
50
+ at = index + word.length
51
+ }
52
+ if (at < utterance.text.length) pieces.push({ text: utterance.text.slice(at) })
53
+ return pieces
54
+ }
55
+
56
+ export function utteranceAt(track: LectureTrack, t: number): Utterance | undefined {
57
+ return track.utterances.find(u => u.t <= t && t < u.t + u.d)
58
+ }
59
+
60
+ export function lastIndexAt(events: readonly { t: number }[], t: number): number {
61
+ let low = 0
62
+ let high = events.length
63
+ while (low < high) {
64
+ const mid = (low + high) >> 1
65
+ if (events[mid]!.t <= t) low = mid + 1
66
+ else high = mid
67
+ }
68
+ return low - 1
69
+ }
70
+
71
+ export interface TrackChapter {
72
+ t: number
73
+ section?: string
74
+ title: string
75
+ }
76
+
77
+ export function trackChapters(events: readonly TimedEvent[], titles: (section: string) => string | undefined = () => undefined): TrackChapter[] {
78
+ const list: TrackChapter[] = []
79
+ let section: string | undefined
80
+ for (const event of events) {
81
+ if (event.type === 'go' && typeof event.data.section === 'string' && event.data.section !== section) {
82
+ section = event.data.section
83
+ list.push({ t: event.t, section, title: titles(section) ?? section })
84
+ } else if (event.type === 'mark' && typeof event.data.label === 'string') {
85
+ list.push({ t: event.t, title: event.data.label })
86
+ }
87
+ }
88
+ return list
89
+ }
90
+
91
+ export interface StepStop {
92
+ t: number
93
+ section: string
94
+ step: number
95
+ }
96
+
97
+ export function stepStops(events: readonly TimedEvent[]): StepStop[] {
98
+ const list: StepStop[] = []
99
+ for (const event of events) {
100
+ const { section, step } = event.data
101
+ if (event.type !== 'go' || typeof section !== 'string') continue
102
+ const at = typeof step === 'number' && Number.isFinite(step) ? step : 0
103
+ const last = list.at(-1)
104
+ if (!last || last.section !== section || last.step !== at) list.push({ t: event.t, section, step: at })
105
+ }
106
+ return list
107
+ }
@@ -0,0 +1,302 @@
1
+ import { DOCUMENT_ENTRY, isRecord } from '@lincyaw/kernel'
2
+ import type { ProductionBinding, TrackEvent } from '@lincyaw/kernel'
3
+
4
+ export const LECTURE_ID = 'presmium.lecture'
5
+
6
+ export const LECTURE_RENDERER = 'lecture'
7
+
8
+ export const PLAYER_RENDERER = 'web'
9
+
10
+ export const TRACK_VERSION = 1
11
+
12
+ export const LECTURES_DIR = 'lectures'
13
+
14
+ export type Word = [t: number, d: number, text: string]
15
+
16
+ export interface Translation {
17
+ text: string
18
+ source: string
19
+ }
20
+
21
+ export interface Utterance {
22
+ id: string
23
+ t: number
24
+ d: number
25
+ text: string
26
+ words?: Word[]
27
+ /** Genuine word boundaries are distinct from interpolated edits and unaligned text. */
28
+ alignment?: 'words' | 'proportional' | 'none'
29
+ section?: string
30
+ step?: number
31
+ translations?: Record<string, Translation>
32
+ clip?: string
33
+ clipId?: string
34
+ audioSha256?: string
35
+ narrationIds?: string[]
36
+ /** Authored no-audio narration; its range comes from actual step boundaries, never reading-speed estimates. */
37
+ recording?: true
38
+ silent?: true
39
+ /** Exact provider/voice metadata of this sentence, when a scripted track uses multiple voices. */
40
+ voice?: Voice
41
+ /**
42
+ * Why the sentence has no audio: a synthesized lecture whose engine could not say it. It plays silent for the length it
43
+ * would take to say (`spokenDuration`), its caption shown; only a lecture's preview has such sentences.
44
+ */
45
+ unsaid?: string
46
+ }
47
+
48
+ // The time a voice takes for a word, as the synthesized lectures of the example say theirs (about 0.34 s in Chinese).
49
+ const WORD_MS = 360
50
+ const MIN_SAID_MS = 500
51
+
52
+ /** About how long a voice takes to say `text`: by its words, as `Intl.Segmenter` finds them in the language. */
53
+ export function spokenDuration(text: string, lang: string): number {
54
+ const words = [...new Intl.Segmenter(lang, { granularity: 'word' }).segment(text)].filter(segment => segment.isWordLike).length
55
+ return Math.max(MIN_SAID_MS, words * WORD_MS)
56
+ }
57
+
58
+ export const CLIP_FILE = /^[0-9a-f]{32}\.(wav|ogg)$/
59
+
60
+ export const CLIPS_DIR = 'clips'
61
+
62
+ export const SCRIPT_TRACK = 'script'
63
+
64
+ /** The lecture a video is made of when none is named: the narrated script, else the only recorded one. */
65
+ export function defaultTrack(ids: readonly string[]): string | undefined {
66
+ return ids.includes(SCRIPT_TRACK) ? SCRIPT_TRACK : ids.length === 1 ? ids[0] : undefined
67
+ }
68
+
69
+ export interface MediaSegment {
70
+ role?: 'recording'
71
+ stepId?: string
72
+ narrationIds?: string[]
73
+ section: string
74
+ start: number
75
+ src: string
76
+ duration: number
77
+ sha256?: string
78
+ clipId?: string
79
+ assetId?: string
80
+ mediaClipId?: string
81
+ sourceStartMs?: number
82
+ volume?: number
83
+ muted?: boolean
84
+ }
85
+
86
+ export interface VideoSegment extends MediaSegment {
87
+ kind: 'camera' | 'screen'
88
+ }
89
+
90
+ export type Voice =
91
+ | { kind: 'silent' }
92
+ | { kind: 'recorded', speaker?: string }
93
+ | { kind: 'synthetic', engine: string, model?: string, voice: string, provider?: string }
94
+
95
+ export interface TrackBlock {
96
+ fp: string
97
+ lines?: number
98
+ }
99
+
100
+ export interface LectureTrack {
101
+ track: typeof TRACK_VERSION
102
+ id: string
103
+ doc: string
104
+ lang: string
105
+ ir: { hash: string, sections?: Record<string, string> }
106
+ voice: Voice
107
+ binding?: ProductionBinding
108
+ durationMs?: number
109
+ audio?: MediaSegment[]
110
+ video?: VideoSegment[]
111
+ blocks: Record<string, TrackBlock>
112
+ utterances: Utterance[]
113
+ /** Derived author times; ordinary audio utterances remain unchanged. */
114
+ captionOverrides?: { section: string, step: number, stepId: string, startMs: number, endMs: number, groups: import('./captions.ts').CaptionGroup[] }[]
115
+ events: TrackEvent[]
116
+ }
117
+
118
+ export const TRACK_ID = /^[\w][\w.-]{0,79}$/
119
+
120
+ function isNumber(value: unknown): value is number {
121
+ return typeof value === 'number' && Number.isFinite(value)
122
+ }
123
+
124
+ function words(value: unknown): Word[] | undefined {
125
+ if (!Array.isArray(value)) return undefined
126
+ return value.filter((w): w is Word => Array.isArray(w) && isNumber(w[0]) && isNumber(w[1]) && typeof w[2] === 'string')
127
+ }
128
+
129
+ function utterance(value: unknown, problems: string[], i: number): Utterance | undefined {
130
+ if (!isRecord(value) || typeof value.id !== 'string' || !isNumber(value.t) || !isNumber(value.d) || typeof value.text !== 'string') {
131
+ problems.push(`utterance ${i} needs id, t, d and text`)
132
+ return undefined
133
+ }
134
+ const translations = isRecord(value.translations)
135
+ ? Object.fromEntries(Object.entries(value.translations).flatMap(([lang, tr]) => isRecord(tr) && typeof tr.text === 'string'
136
+ ? [[lang, { text: tr.text, source: typeof tr.source === 'string' ? tr.source : '' }]]
137
+ : []))
138
+ : undefined
139
+ return {
140
+ id: value.id,
141
+ t: value.t,
142
+ d: value.d,
143
+ text: value.text,
144
+ ...words(value.words) ? { words: words(value.words) } : {},
145
+ ...value.alignment === 'words' || value.alignment === 'proportional' || value.alignment === 'none' ? { alignment: value.alignment } : {},
146
+ ...typeof value.section === 'string' ? { section: value.section } : {},
147
+ ...isNumber(value.step) ? { step: value.step } : {},
148
+ ...translations ? { translations } : {},
149
+ ...typeof value.clipId === 'string' ? { clipId: value.clipId } : {},
150
+ ...typeof value.audioSha256 === 'string' ? { audioSha256: value.audioSha256 } : {},
151
+ ...Array.isArray(value.narrationIds) && value.narrationIds.every(id => typeof id === 'string') ? { narrationIds: value.narrationIds as string[] } : {},
152
+ ...value.recording === true ? { recording: true as const } : {},
153
+ ...value.silent === true ? { silent: true as const } : {},
154
+ ...typeof value.clip === 'string' && CLIP_FILE.test(value.clip) ? { clip: value.clip } : {},
155
+ ...isRecord(value.voice) ? { voice: voice(value.voice) } : {},
156
+ ...typeof value.unsaid === 'string' ? { unsaid: value.unsaid } : {},
157
+ }
158
+ }
159
+
160
+ function segment(value: unknown): MediaSegment | undefined {
161
+ if (!isRecord(value) || typeof value.section !== 'string' || !isNumber(value.start) || typeof value.src !== 'string' || !isNumber(value.duration)) return undefined
162
+ return { ...value.role === 'recording' ? { role: 'recording' as const } : {}, ...typeof value.stepId === 'string' ? { stepId: value.stepId } : {}, ...Array.isArray(value.narrationIds) && value.narrationIds.every(id => typeof id === 'string') ? { narrationIds: value.narrationIds as string[] } : {}, section: value.section, start: value.start, src: value.src, duration: value.duration, ...typeof value.sha256 === 'string' ? { sha256: value.sha256 } : {}, ...typeof value.clipId === 'string' ? { clipId: value.clipId } : {}, ...typeof value.assetId === 'string' ? { assetId: value.assetId } : {}, ...typeof value.mediaClipId === 'string' ? { mediaClipId: value.mediaClipId } : {}, ...isNumber(value.sourceStartMs) && value.sourceStartMs >= 0 ? { sourceStartMs: value.sourceStartMs } : {}, ...isNumber(value.volume) && value.volume >= 0 && value.volume <= 1 ? { volume: value.volume } : {}, ...typeof value.muted === 'boolean' ? { muted: value.muted } : {} }
163
+ }
164
+
165
+ function captionOverride(value: unknown): NonNullable<LectureTrack['captionOverrides']>[number] | undefined {
166
+ if (!isRecord(value) || typeof value.section !== 'string' || !isNumber(value.step) || typeof value.stepId !== 'string'
167
+ || !isNumber(value.startMs) || !isNumber(value.endMs) || value.endMs <= value.startMs || !Array.isArray(value.groups)) return
168
+ const start = value.startMs, end = value.endMs
169
+ let previous = start
170
+ for (const group of value.groups) {
171
+ if (!isRecord(group) || typeof group.id !== 'string' || typeof group.text !== 'string' || !isNumber(group.t) || !isNumber(group.d)
172
+ || group.d <= 0 || group.t < previous || group.t + group.d > end || group.alignment !== 'manual') return
173
+ previous = group.t + group.d
174
+ }
175
+ return value as unknown as NonNullable<LectureTrack['captionOverrides']>[number]
176
+ }
177
+
178
+ function irOf(value: unknown): LectureTrack['ir'] {
179
+ const hash = isRecord(value) && typeof value.hash === 'string' ? value.hash : ''
180
+ const sections = isRecord(value) && isRecord(value.sections)
181
+ ? Object.fromEntries(Object.entries(value.sections).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
182
+ : undefined
183
+ return sections ? { hash, sections } : { hash }
184
+ }
185
+
186
+ function voice(value: unknown): Voice {
187
+ if (isRecord(value) && value.kind === 'silent') return { kind: 'silent' }
188
+ if (isRecord(value) && value.kind === 'synthetic' && typeof value.engine === 'string' && typeof value.voice === 'string') {
189
+ return {
190
+ kind: 'synthetic',
191
+ engine: value.engine,
192
+ voice: value.voice,
193
+ ...typeof value.model === 'string' ? { model: value.model } : {},
194
+ ...typeof value.provider === 'string' ? { provider: value.provider } : {},
195
+ }
196
+ }
197
+ return { kind: 'recorded', ...isRecord(value) && typeof value.speaker === 'string' ? { speaker: value.speaker } : {} }
198
+ }
199
+
200
+ export function parseTrack(value: unknown): { track?: LectureTrack, problems: string[] } {
201
+ const problems: string[] = []
202
+ if (!isRecord(value) || value.track !== TRACK_VERSION || typeof value.id !== 'string') {
203
+ return { problems: [`not a lecture track of version ${TRACK_VERSION} with an id`] }
204
+ }
205
+ const events: TrackEvent[] = []
206
+ for (const [i, event] of (Array.isArray(value.events) ? value.events : []).entries()) {
207
+ if (Array.isArray(event) && isNumber(event[0]) && typeof event[1] === 'string' && isRecord(event[2])) events.push([event[0], event[1], event[2]])
208
+ else problems.push(`event ${i} is not [ms, type, data]`)
209
+ }
210
+ const utterances = (Array.isArray(value.utterances) ? value.utterances : []).flatMap((u, i) => utterance(u, problems, i) ?? [])
211
+ const blocks = isRecord(value.blocks)
212
+ ? Object.fromEntries(Object.entries(value.blocks).flatMap(([key, b]) => isRecord(b) && typeof b.fp === 'string' ? [[key, { fp: b.fp, ...isNumber(b.lines) ? { lines: b.lines } : {} }]] : []))
213
+ : {}
214
+ const audio = Array.isArray(value.audio) ? value.audio.flatMap(a => segment(a) ?? []) : undefined
215
+ const video = Array.isArray(value.video)
216
+ ? value.video.flatMap((v) => {
217
+ const base = segment(v)
218
+ return base && isRecord(v) && (v.kind === 'camera' || v.kind === 'screen') ? [{ ...base, kind: v.kind as VideoSegment['kind'] }] : []
219
+ })
220
+ : undefined
221
+ return {
222
+ track: {
223
+ track: TRACK_VERSION,
224
+ id: value.id,
225
+ doc: typeof value.doc === 'string' ? value.doc : DOCUMENT_ENTRY,
226
+ lang: typeof value.lang === 'string' ? value.lang : 'zh-CN',
227
+ ir: irOf(value.ir),
228
+ voice: voice(value.voice),
229
+ ...isNumber(value.durationMs) && value.durationMs >= 0 ? { durationMs: value.durationMs } : {},
230
+ ...isRecord(value.binding) && typeof value.binding.planId === 'string' && typeof value.binding.irRevision === 'string' && isRecord(value.binding.source) && typeof value.binding.source.documentId === 'string' && typeof value.binding.source.version === 'string' && typeof value.binding.source.fingerprint === 'string' ? { binding: value.binding as unknown as ProductionBinding } : {},
231
+ ...audio?.length ? { audio } : {},
232
+ ...video?.length ? { video } : {},
233
+ blocks,
234
+ ...Array.isArray(value.captionOverrides) ? { captionOverrides: value.captionOverrides.flatMap(value => captionOverride(value) ?? []) } : {},
235
+ utterances: utterances.sort((a, b) => a.t - b.t),
236
+ events: sortEvents(events),
237
+ },
238
+ problems,
239
+ }
240
+ }
241
+
242
+ export function sortEvents(events: TrackEvent[]): TrackEvent[] {
243
+ return events.map((event, i) => [event, i] as const).sort(([a, i], [b, j]) => a[0] - b[0] || i - j).map(([event]) => event)
244
+ }
245
+
246
+ export function formatTrack(track: LectureTrack): string {
247
+ const lines: string[] = []
248
+ const entries = Object.entries(track).filter(([, value]) => value !== undefined)
249
+ entries.forEach(([key, value], i) => {
250
+ const comma = i < entries.length - 1 ? ',' : ''
251
+ if (Array.isArray(value) && value.length > 0) {
252
+ lines.push(` ${JSON.stringify(key)}: [`)
253
+ value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j < value.length - 1 ? ',' : ''}`))
254
+ lines.push(` ]${comma}`)
255
+ } else if (key === 'blocks' && isRecord(value) && Object.keys(value).length > 0) {
256
+ const blocks = Object.entries(value)
257
+ lines.push(` "blocks": {`)
258
+ blocks.forEach(([block, data], j) => lines.push(` ${JSON.stringify(block)}: ${JSON.stringify(data)}${j < blocks.length - 1 ? ',' : ''}`))
259
+ lines.push(` }${comma}`)
260
+ } else {
261
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`)
262
+ }
263
+ })
264
+ return `{\n${lines.join('\n')}\n}\n`
265
+ }
266
+
267
+ export function eventLine(text: string, index: number): number | undefined {
268
+ const lines = text.split('\n')
269
+ const start = lines.findIndex(line => line.startsWith(' "events": ['))
270
+ return start === -1 || start + 1 + index >= lines.length ? undefined : start + 2 + index
271
+ }
272
+
273
+ export function utteranceLine(text: string, id: string): number | undefined {
274
+ const lines = text.split('\n')
275
+ const from = lines.findIndex(line => line.trimStart().startsWith('"utterances"'))
276
+ const at = lines.findIndex((line, i) => i > from && line.includes(`"id":${JSON.stringify(id)}`))
277
+ return from === -1 || at === -1 ? undefined : at + 1
278
+ }
279
+
280
+ export function trackDuration(track: LectureTrack): number {
281
+ return Math.max(
282
+ 0, track.durationMs ?? 0,
283
+ ...(track.audio ?? []).map(a => (a.start + a.duration) * 1000),
284
+ ...track.utterances.map(u => u.t + u.d),
285
+ ...track.events.map(([t, type, data]) => (type === 'pause' && typeof data.ms === 'number' ? t + data.ms : t)),
286
+ )
287
+ }
288
+
289
+ export interface AudioSegment {
290
+ clipId?: string
291
+ src: string
292
+ start: number
293
+ duration: number
294
+ sourceStartMs?: number
295
+ volume?: number
296
+ muted?: boolean
297
+ }
298
+
299
+ export function audioSegments(track: LectureTrack): AudioSegment[] {
300
+ const said = track.utterances.filter(u => u.clip).map(u => ({ src: `${CLIPS_DIR}/${u.clip}`, start: u.t, duration: u.d, ...u.clipId ? { clipId: u.clipId } : {} }))
301
+ return [...said, ...(track.audio ?? []).map(a => ({ src: a.src, start: a.start * 1000, duration: a.duration * 1000, ...a.clipId ? { clipId: a.clipId } : {}, ...a.sourceStartMs !== undefined ? { sourceStartMs: a.sourceStartMs } : {}, ...a.volume !== undefined ? { volume: a.volume } : {}, ...a.muted !== undefined ? { muted: a.muted } : {} }))]
302
+ }
@@ -0,0 +1,34 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
3
+ import type { LectureTrack, Utterance } from './track.ts'
4
+
5
+ export function translationDigest(text: string): string {
6
+ return `sha256:${bytesToHex(sha256(utf8ToBytes(text)))}`
7
+ }
8
+
9
+ export function isStale(utterance: Utterance, lang: string): boolean {
10
+ const translation = utterance.translations?.[lang]
11
+ return Boolean(translation && translation.source !== translationDigest(utterance.text))
12
+ }
13
+
14
+ export interface TranslationItem {
15
+ id: string
16
+ section?: string
17
+ text: string
18
+ source: string
19
+ translation?: string
20
+ stale?: boolean
21
+ }
22
+
23
+ export function translationItems(track: LectureTrack, lang: string): TranslationItem[] {
24
+ return track.utterances.map((u) => {
25
+ const done = u.translations?.[lang]
26
+ return {
27
+ id: u.id,
28
+ ...u.section ? { section: u.section } : {},
29
+ text: u.text,
30
+ source: translationDigest(u.text),
31
+ ...done ? { translation: done.text, stale: isStale(u, lang) } : {},
32
+ }
33
+ })
34
+ }