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