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