@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/presmium-lecture.mjs +12 -0
- package/package.json +68 -3
- package/src/client/ChapterList.tsx +50 -0
- package/src/client/LectureView.css +21 -0
- package/src/client/LectureView.tsx +84 -0
- package/src/client/PlayLectureButton.tsx +21 -0
- package/src/client/PlayerPanel.css +120 -0
- package/src/client/PlayerPanel.tsx +194 -0
- package/src/client/RecorderPanel.css +46 -0
- package/src/client/RecorderPanel.tsx +282 -0
- package/src/client/TranscriptList.tsx +28 -0
- package/src/client/clock.ts +169 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/media.ts +86 -0
- package/src/client/playback.ts +140 -0
- package/src/client/player.ts +202 -0
- package/src/client/plugin.ts +33 -0
- package/src/client/pointer-ink.ts +143 -0
- package/src/client/production-playback.ts +188 -0
- package/src/client/recorder.ts +245 -0
- package/src/client/source.ts +109 -0
- package/src/client/state.ts +45 -0
- package/src/client/viewer.ts +37 -0
- package/src/domain/audience.ts +108 -0
- package/src/domain/caption-timing.ts +59 -0
- package/src/domain/captions.ts +59 -0
- package/src/domain/config.ts +67 -0
- package/src/domain/drift.ts +159 -0
- package/src/domain/editing.ts +112 -0
- package/src/domain/format.ts +13 -0
- package/src/domain/index.ts +50 -0
- package/src/domain/messages.ts +324 -0
- package/src/domain/narration.ts +160 -0
- package/src/domain/plan.ts +319 -0
- package/src/domain/production-audio.ts +39 -0
- package/src/domain/production.ts +136 -0
- package/src/domain/recording.ts +130 -0
- package/src/domain/script-track.ts +176 -0
- package/src/domain/timing.ts +107 -0
- package/src/domain/track.ts +302 -0
- package/src/domain/translation.ts +34 -0
- package/src/domain/vtt.ts +68 -0
- package/src/domain/words.ts +114 -0
- package/src/domain/writeback.ts +129 -0
- package/src/node/audio-authoring.ts +53 -0
- package/src/node/check.ts +93 -0
- package/src/node/cli.ts +82 -0
- package/src/node/clips.ts +10 -0
- package/src/node/engines.ts +43 -0
- package/src/node/export.ts +331 -0
- package/src/node/ffmpeg.ts +76 -0
- package/src/node/latest-ir.ts +54 -0
- package/src/node/plan.ts +35 -0
- package/src/node/presmium.ts +59 -0
- package/src/node/production-assets.ts +112 -0
- package/src/node/production.ts +102 -0
- package/src/node/renderer.ts +182 -0
- package/src/node/roles.ts +23 -0
- package/src/node/routes/production.ts +51 -0
- package/src/node/routes/recordings.ts +122 -0
- package/src/node/routes/serve-file.ts +38 -0
- package/src/node/routes/served-deck.ts +61 -0
- package/src/node/routes/server.ts +12 -0
- package/src/node/routes/tracks.ts +98 -0
- package/src/node/routes/voiceover.ts +23 -0
- package/src/node/store.ts +192 -0
- package/src/node/synthesize.ts +190 -0
- package/src/node/tools.ts +170 -0
- package/src/node/transcribe.ts +53 -0
- package/src/node/voice-generation.ts +15 -0
- package/src/node/voice-source.ts +34 -0
- 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
|
+
}
|