@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,319 @@
|
|
|
1
|
+
import { irBlocks, parseDuration } from '@lincyaw/kernel'
|
|
2
|
+
import type { CodeNode, IRBlock, IRDocument, IRSection, RecordedRun, SourcePos, SpeechVoice, TimeRange, VoiceTarget, WordSpan } from '@lincyaw/kernel'
|
|
3
|
+
import { narrationVoice } from './config.ts'
|
|
4
|
+
import { blockKey, codeLineCount, lastNamedLine } from './drift.ts'
|
|
5
|
+
import type { CueCommand, CueMark, SpokenText } from './narration.ts'
|
|
6
|
+
import { cueProblem, parseCue, spokenText, TARGETED } from './narration.ts'
|
|
7
|
+
import { LECTURE_RENDERER, PLAYER_RENDERER } from './track.ts'
|
|
8
|
+
|
|
9
|
+
export interface PlannedCue {
|
|
10
|
+
name: CueCommand
|
|
11
|
+
offset: number
|
|
12
|
+
data: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PlannedPiece {
|
|
16
|
+
id?: string
|
|
17
|
+
text: string
|
|
18
|
+
spoken: string
|
|
19
|
+
spans: WordSpan[]
|
|
20
|
+
cues: PlannedCue[]
|
|
21
|
+
pause?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PlannedParagraph {
|
|
25
|
+
narrationId?: string
|
|
26
|
+
sourceKind?: 'none' | 'tts' | 'recording' | 'unset'
|
|
27
|
+
step: number
|
|
28
|
+
mode?: 'during' | 'after'
|
|
29
|
+
pieces: PlannedPiece[]
|
|
30
|
+
source?: SourcePos
|
|
31
|
+
voice?: SpeechVoice
|
|
32
|
+
voiceIssue?: string
|
|
33
|
+
voiceTarget?: VoiceTarget
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlannedRecording {
|
|
37
|
+
id: string
|
|
38
|
+
step: number
|
|
39
|
+
stepId: string
|
|
40
|
+
assetId: string
|
|
41
|
+
sourceRange?: TimeRange
|
|
42
|
+
mode: 'during' | 'after'
|
|
43
|
+
text: string
|
|
44
|
+
narrationIds: string[]
|
|
45
|
+
cues: PlannedCue[]
|
|
46
|
+
issue?: 'recording-cue-unsupported' | 'recording-start-conflict'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PlannedSection {
|
|
50
|
+
id: string
|
|
51
|
+
title: string
|
|
52
|
+
steps: number
|
|
53
|
+
from: 'narration'
|
|
54
|
+
paragraphs: PlannedParagraph[]
|
|
55
|
+
recordings?: PlannedRecording[]
|
|
56
|
+
holds: number[]
|
|
57
|
+
trailingPauseMs?: number[]
|
|
58
|
+
webStep(step: number): number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PlanProblem {
|
|
62
|
+
message: string
|
|
63
|
+
source?: SourcePos
|
|
64
|
+
reportedAtCompile?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LecturePlan {
|
|
68
|
+
sections: PlannedSection[]
|
|
69
|
+
problems: PlanProblem[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSaid(block: IRBlock): boolean {
|
|
73
|
+
return !block.only || block.only.includes(LECTURE_RENDERER)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isShown(block: IRBlock): boolean {
|
|
77
|
+
return !block.only || block.only.includes(PLAYER_RENDERER)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function named(doc: IRDocument, section: IRSection, to: string): { section: IRSection, block: IRBlock } | undefined {
|
|
81
|
+
const find = (s: IRSection) => [...irBlocks(s.blocks)].find(b => b.id === to && b.role === 'visual')
|
|
82
|
+
const own = find(section)
|
|
83
|
+
if (own) return { section, block: own }
|
|
84
|
+
for (const other of doc.sections) {
|
|
85
|
+
const block = find(other)
|
|
86
|
+
if (block) return { section: other, block }
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function webSteps(section: IRSection): (step: number) => number {
|
|
92
|
+
const map = section.stepMap
|
|
93
|
+
if (!map) return step => step
|
|
94
|
+
return (step) => {
|
|
95
|
+
const original = map.indexOf(step)
|
|
96
|
+
return original === -1 ? step : original
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cssString(value: string): string {
|
|
101
|
+
return `"${value.replace(/["\\]/g, '\\$&').replace(/\n/g, '\\a ')}"`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cueData(name: CueCommand, arg: string, mark: CueMark, section: IRSection, block: IRBlock): Record<string, unknown> {
|
|
105
|
+
const key = blockKey(section.id, block.id)
|
|
106
|
+
const lines = mark.lines ? { lines: mark.lines } : {}
|
|
107
|
+
switch (name) {
|
|
108
|
+
case 'spot':
|
|
109
|
+
case 'hint':
|
|
110
|
+
return mark.sel ? { selector: `[data-section=${cssString(section.id)}] .pm-block[data-block=${cssString(block.id)}] ${mark.sel}` } : { block: key, ...lines }
|
|
111
|
+
case 'point': {
|
|
112
|
+
const first = mark.lines ? Number(/\d+/.exec(mark.lines)?.[0]) : undefined
|
|
113
|
+
return { block: key, path: [[0, 0.5, 0.5]], ...first ? { line: first } : {} }
|
|
114
|
+
}
|
|
115
|
+
case 'ink':
|
|
116
|
+
return mark.lines ? { block: key, tool: 'marker', lines: mark.lines } : { block: key, tool: 'marker', range: [0, 0, 1, 1] }
|
|
117
|
+
case 'click':
|
|
118
|
+
return { block: key, action: arg }
|
|
119
|
+
case 'run': {
|
|
120
|
+
const recorded = (block.node as CodeNode).outputs?.[0] as RecordedRun | undefined
|
|
121
|
+
return { section: section.id, block: block.id, result: recorded?.cacheKey ? `freeze:${recorded.cacheKey}` : 'live' }
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
return {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Reader {
|
|
129
|
+
doc: IRDocument
|
|
130
|
+
section: IRSection
|
|
131
|
+
target?: IRBlock
|
|
132
|
+
source?: SourcePos
|
|
133
|
+
problems: PlanProblem[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function planCues(spoken: SpokenText, at: Reader): PlannedCue[] {
|
|
137
|
+
const cues: PlannedCue[] = []
|
|
138
|
+
const problem = (message: string, reportedAtCompile = false) => at.problems.push({ message: `section ${at.section.id}: ${message}`, source: at.source, ...reportedAtCompile ? { reportedAtCompile } : {} })
|
|
139
|
+
for (const mark of spoken.cues) {
|
|
140
|
+
const parsed = parseCue(mark.command)
|
|
141
|
+
if ('problem' in parsed) {
|
|
142
|
+
problem(cueProblem(mark.command, parsed.problem, at.doc.meta.lang), true)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const { name, arg } = parsed
|
|
146
|
+
if (name === 'next') {
|
|
147
|
+
cues.push({ name, offset: mark.offset, data: {} })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (name === 'pause' || name === 'mark') {
|
|
151
|
+
cues.push({ name, offset: mark.offset, data: name === 'pause' ? { ms: parseDuration(arg)! } : { label: arg } })
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const found = mark.to ? named(at.doc, at.section, mark.to) : at.target && { section: at.section, block: at.target }
|
|
155
|
+
if (!found) {
|
|
156
|
+
problem(mark.to ? `{cue to=${mark.to}}\`${mark.command}\`: the deck has no block ${mark.to}` : `{cue}\`${mark.command}\` has no block before it to act on; name one with to=<label>`)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (!isShown(found.block)) {
|
|
160
|
+
problem(`{cue}\`${mark.command}\`: block ${found.block.id} is not shown in the web form, where the lecture plays`)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const lines = codeLineCount(found.block)
|
|
164
|
+
const last = mark.lines ? lastNamedLine(mark.lines) : undefined
|
|
165
|
+
if (last !== undefined && lines !== undefined && last > lines) {
|
|
166
|
+
problem(`{cue}\`${mark.command}\` names lines ${mark.lines} of ${found.block.id}, which has ${lines}`)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (name === 'run' && found.block.node.type !== 'code') {
|
|
170
|
+
problem(`{cue}\`run\` on ${found.block.id}, which is no code block`)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (!TARGETED.has(name)) continue
|
|
174
|
+
cues.push({ name, offset: mark.offset, data: cueData(name, arg, mark, found.section, found.block) })
|
|
175
|
+
}
|
|
176
|
+
return cues
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pieces(spoken: SpokenText, cues: PlannedCue[]): PlannedPiece[] {
|
|
180
|
+
const list: PlannedPiece[] = []
|
|
181
|
+
let from = 0
|
|
182
|
+
let pending: PlannedCue[] = []
|
|
183
|
+
const cut = (to: number, pause?: number) => {
|
|
184
|
+
const text = spoken.text.slice(from, to)
|
|
185
|
+
const lead = text.length - text.trimStart().length
|
|
186
|
+
const start = from + lead
|
|
187
|
+
const end = from + text.trimEnd().length
|
|
188
|
+
const shift = (offset: number) => spoken.spans.filter(s => s.end <= offset).reduce((sum, s) => sum + (s.to - s.from) - (s.end - s.start), 0)
|
|
189
|
+
const spans = spoken.spans.filter(s => s.start >= start && s.end <= end)
|
|
190
|
+
.map(s => ({ from: s.from - (start + shift(start)), to: s.to - (start + shift(start)), start: s.start - start, end: s.end - start }))
|
|
191
|
+
list.push({
|
|
192
|
+
text: spoken.text.slice(start, end),
|
|
193
|
+
spoken: spoken.spoken.slice(start + shift(start), end + shift(end)),
|
|
194
|
+
spans,
|
|
195
|
+
cues: pending.map(c => ({ ...c, offset: Math.max(0, Math.min(c.offset - start, end - start)) })),
|
|
196
|
+
...pause ? { pause } : {},
|
|
197
|
+
})
|
|
198
|
+
pending = []
|
|
199
|
+
from = to
|
|
200
|
+
}
|
|
201
|
+
for (const cue of cues) {
|
|
202
|
+
pending.push(cue)
|
|
203
|
+
if (cue.name === 'pause') cut(cue.offset, cue.data.ms as number)
|
|
204
|
+
}
|
|
205
|
+
if (from < spoken.text.length || pending.length > 0 || list.length === 0) cut(spoken.text.length)
|
|
206
|
+
return list.filter(piece => piece.text || piece.cues.length > 0 || piece.pause)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function narration(section: IRSection): { block: IRBlock, target?: IRBlock }[] {
|
|
210
|
+
const list: { block: IRBlock, target?: IRBlock }[] = []
|
|
211
|
+
const walk = (blocks: IRBlock[], target: IRBlock | undefined) => {
|
|
212
|
+
for (const block of blocks) {
|
|
213
|
+
if (block.role === 'visual') {
|
|
214
|
+
if (isShown(block)) target = block
|
|
215
|
+
if (block.node.type === 'component' && block.node.children) walk(block.node.children, target)
|
|
216
|
+
} else if (isSaid(block)) {
|
|
217
|
+
list.push({ block, target })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
walk(section.blocks, undefined)
|
|
222
|
+
return list
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface PlannedAnimation { blockId: string, step: number, durationMs?: number }
|
|
226
|
+
|
|
227
|
+
/** Changes that start an animation; static built-ins do not invent an animation duration. */
|
|
228
|
+
export function plannedAnimations(section: IRSection): PlannedAnimation[] {
|
|
229
|
+
const animations: PlannedAnimation[] = []
|
|
230
|
+
for (const block of irBlocks(section.blocks)) {
|
|
231
|
+
if (block.node.type !== 'component' || !isShown(block) || !block.node.durations && !(block.node.plugin === 'presmium.motion' && block.node.name === 'PmScene')) continue
|
|
232
|
+
const duration = (step: number, local: number) => {
|
|
233
|
+
const ms = block.node.type === 'component' ? block.node.durations?.[local] : undefined
|
|
234
|
+
animations.push({ blockId: block.id, step, ...typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? { durationMs: ms } : {} })
|
|
235
|
+
}
|
|
236
|
+
if (block.stepStates) {
|
|
237
|
+
for (const [step, state] of block.stepStates.entries()) {
|
|
238
|
+
const previous = block.stepStates[step - 1]
|
|
239
|
+
if (!state.visible || previous?.visible && previous.localStep === state.localStep && previous.state === state.state && JSON.stringify(previous.parameters) === JSON.stringify(state.parameters)) continue
|
|
240
|
+
duration(step, state.localStep)
|
|
241
|
+
}
|
|
242
|
+
} else for (let local = 0; local <= (block.span ?? 0); local++) if (block.at + local <= section.steps) duration(block.at + local, local)
|
|
243
|
+
}
|
|
244
|
+
return animations
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Effective minimum step holds: declared animations and authored Production stay durations. */
|
|
248
|
+
function stepHolds(section: IRSection, doc: IRDocument): number[] {
|
|
249
|
+
const holds = Array.from({ length: section.steps + 1 }, () => 0)
|
|
250
|
+
for (const animation of plannedAnimations(section)) holds[animation.step] = Math.max(holds[animation.step]!, animation.durationMs ?? 0)
|
|
251
|
+
for (const [step, id] of (section.stepIds ?? []).entries()) {
|
|
252
|
+
const production = { ...doc.production?.defaults, ...doc.production?.sectionOverrides?.[section.id], ...doc.production?.stepOverrides[id] }
|
|
253
|
+
holds[step] = Math.max(holds[step] ?? 0, production.minimumHoldMs ?? 0)
|
|
254
|
+
}
|
|
255
|
+
return holds
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function lecturePlan(doc: IRDocument): LecturePlan {
|
|
259
|
+
const problems: PlanProblem[] = []
|
|
260
|
+
const sections: PlannedSection[] = doc.sections.map((section) => {
|
|
261
|
+
const paragraphs: PlannedParagraph[] = []
|
|
262
|
+
for (const { block, target } of narration(section)) {
|
|
263
|
+
if (block.node.type !== 'markdown') continue
|
|
264
|
+
const spoken = spokenText(block.node.mdast)
|
|
265
|
+
const cues = planCues(spoken, { doc, section, target, source: block.source, problems })
|
|
266
|
+
const voice = narrationVoice(doc, section, block.id, block.at)
|
|
267
|
+
const stepId = section.stepIds?.[block.at]
|
|
268
|
+
const source = (stepId ? doc.production?.stepOverrides[stepId]?.voiceSource : undefined) ?? doc.production?.sectionOverrides?.[section.id]?.voiceSource ?? doc.production?.defaults.voiceSource
|
|
269
|
+
const pointer = block.source.pointer
|
|
270
|
+
const narrationId = pointer?.startsWith('/narrations/') ? pointer.slice('/narrations/'.length).replaceAll('~1', '/').replaceAll('~0', '~') : undefined
|
|
271
|
+
paragraphs.push({ ...narrationId ? { narrationId } : {}, sourceKind: source?.type ?? (voice.voice ? 'tts' : 'unset'), step: Math.min(block.at, section.steps), mode: block.node.narration?.mode, pieces: pieces(spoken, cues), source: block.source, voice: voice.voice, voiceIssue: voice.issue, voiceTarget: voice.target })
|
|
272
|
+
}
|
|
273
|
+
paragraphs.sort((a, b) => a.step - b.step)
|
|
274
|
+
const settings = (section.stepIds ?? []).map(id => ({ ...doc.production?.defaults, ...doc.production?.sectionOverrides?.[section.id], ...doc.production?.stepOverrides[id] }))
|
|
275
|
+
const trailingPauseMs = settings.some(value => value.trailingPauseMs !== undefined) ? settings.map(value => value.trailingPauseMs ?? 500) : undefined
|
|
276
|
+
const recordings: PlannedRecording[] = settings.flatMap((setting, step) => {
|
|
277
|
+
if (setting.voiceSource?.type !== 'recording') return []
|
|
278
|
+
const own = paragraphs.filter(paragraph => paragraph.step === step), cues: PlannedCue[] = []
|
|
279
|
+
let text = false, issue: PlannedRecording['issue']
|
|
280
|
+
const mode = own[0]?.mode ?? (setting.narrationStart === 'after-animation' ? 'after' : 'during')
|
|
281
|
+
for (const paragraph of own) {
|
|
282
|
+
if ((paragraph.mode ?? 'during') !== mode) issue = 'recording-start-conflict'
|
|
283
|
+
for (const piece of paragraph.pieces) {
|
|
284
|
+
if (piece.pause || piece.cues.some(cue => cue.name === 'next' || cue.name === 'pause' || cue.offset > 0 || text)) issue = 'recording-cue-unsupported'
|
|
285
|
+
cues.push(...piece.cues)
|
|
286
|
+
if (piece.text.trim()) text = true
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const stepId = section.stepIds![step]!
|
|
290
|
+
return [{ id: `recording:${stepId}`, step, stepId, assetId: setting.voiceSource.assetId, ...setting.voiceSource.sourceRange ? { sourceRange: { ...setting.voiceSource.sourceRange } } : {}, mode, text: own.flatMap(paragraph => paragraph.pieces.map(piece => piece.text)).filter(Boolean).join('\n\n'), narrationIds: [...new Set(own.flatMap(paragraph => paragraph.narrationId ?? []))], cues, ...issue ? { issue } : {} }]
|
|
291
|
+
})
|
|
292
|
+
return { id: section.id, title: section.title, steps: section.steps, from: 'narration', paragraphs, recordings, holds: stepHolds(section, doc), ...trailingPauseMs ? { trailingPauseMs } : {}, webStep: webSteps(section) }
|
|
293
|
+
})
|
|
294
|
+
let n = 0
|
|
295
|
+
for (const section of sections) {
|
|
296
|
+
for (const paragraph of section.paragraphs) for (const piece of paragraph.pieces) if (piece.text) piece.id = `u${++n}`
|
|
297
|
+
}
|
|
298
|
+
return { sections, problems }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The most words of a section that transcription takes as hints (an engine's hotwords, or Whisper's prompt). */
|
|
302
|
+
const HINT_WORDS = 100
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The words a speaker is likely to say in a section, its title's and its narration's, as hints for the
|
|
306
|
+
* transcription of a recording: they tell homophones apart (趟 and 烫, 数 and 树).
|
|
307
|
+
*/
|
|
308
|
+
export function sectionHints(section: PlannedSection, lang: string): string[] {
|
|
309
|
+
const segmenter = new Intl.Segmenter(lang, { granularity: 'word' })
|
|
310
|
+
const text = [section.title, ...section.paragraphs.flatMap(paragraph => paragraph.pieces.map(piece => piece.text))].join('\n')
|
|
311
|
+
const words = [...segmenter.segment(text)].filter(part => part.isWordLike).map(part => part.segment)
|
|
312
|
+
return [...new Set(words)].slice(0, HINT_WORDS)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function plannedUtterances(plan: LecturePlan): (PlannedPiece & { id: string, section: string, step: number, source?: SourcePos, narrationId?: string, sourceKind?: PlannedParagraph['sourceKind'], voice?: SpeechVoice, voiceIssue?: string, voiceTarget?: VoiceTarget })[] {
|
|
316
|
+
return plan.sections.flatMap(section => section.paragraphs.flatMap(paragraph => paragraph.pieces
|
|
317
|
+
.filter((piece): piece is PlannedPiece & { id: string } => Boolean(piece.id))
|
|
318
|
+
.map(piece => ({ ...piece, section: section.id, step: paragraph.step, narrationId: paragraph.narrationId, sourceKind: paragraph.sourceKind, voice: paragraph.voice, voiceIssue: paragraph.voiceIssue, voiceTarget: paragraph.voiceTarget, ...paragraph.source ? { source: paragraph.source } : {} }))))
|
|
319
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IRDocument, ProductionAssetInspection, ProductionAudioClip, ProductionPage } from '@lincyaw/kernel'
|
|
2
|
+
import { deckLocale } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export interface ProductionAudioIssue { code: string, sectionId?: string, stepId: string, assetId: string, mediaClipId: string }
|
|
5
|
+
|
|
6
|
+
const messages: Record<string, { 'zh-CN': string, en: string }> = {
|
|
7
|
+
'production.asset-missing': { 'zh-CN': '找不到此音频资产声明。', en: 'This audio asset is not declared in the document.' },
|
|
8
|
+
'production.asset-kind': { 'zh-CN': '当前只支持本地音频;视频尚未支持。', en: 'Only local audio is supported; video is unavailable.' },
|
|
9
|
+
'production.asset-path': { 'zh-CN': '音频须为同文稿的非隐藏本地文件;远程或跨文稿素材尚未支持。', en: 'Audio must be a non-hidden local file in this document; remote and shared assets are unsupported.' },
|
|
10
|
+
'production.asset-file': { 'zh-CN': '音频文件不存在、不可读取或不是本地常规文件。', en: 'The audio is missing, unreadable, or not a local regular file.' },
|
|
11
|
+
'production.asset-size': { 'zh-CN': '音频文件须非空且不超过 16 MiB。', en: 'The audio file must be nonempty and at most 16 MiB.' },
|
|
12
|
+
'production.asset-count': { 'zh-CN': '本次制作最多引用 32 份音频资产。', en: 'A production can reference at most 32 audio assets.' },
|
|
13
|
+
'production.asset-decoded-size': { 'zh-CN': '引用音频的解码总量超过 256 MiB,请减少或拆分素材。', en: 'The referenced audio exceeds the 256 MiB decoded-data limit; reduce or split the assets.' },
|
|
14
|
+
'production.asset-format': { 'zh-CN': '此文件不是受支持的单音轨音频,或媒体数据已损坏。', en: 'The file is not supported single-track audio, or its media data is damaged.' },
|
|
15
|
+
'production.asset-duration': { 'zh-CN': '无法确认音频时长,或时长超过 10 分钟。', en: 'The audio duration is unconfirmed or exceeds ten minutes.' },
|
|
16
|
+
'production.asset-fingerprint': { 'zh-CN': '音频字节与声明的 SHA-256 不符,请检查或重新采用素材。', en: 'The audio bytes do not match the declared SHA-256; review or adopt the asset again.' },
|
|
17
|
+
'production.asset-changed': { 'zh-CN': '音频文件已变化,请重新准备当前编排。', en: 'The audio file changed; prepare the current sequence again.' },
|
|
18
|
+
'production.media-range': { 'zh-CN': '裁切出点须晚于入点且不超过实际音频时长。', en: 'The out point must follow the in point and stay within the actual audio duration.' },
|
|
19
|
+
'production.media-step-unknown': { 'zh-CN': '音频只能附着到时段已确认的步骤,请先修正配音或时序。', en: 'Audio requires a confirmed step range; resolve voiceover or timing first.' },
|
|
20
|
+
'production.media-step-range': { 'zh-CN': '音频片段超出本步时段;请调整裁切、偏移或本步停留,不会自动跨步。', en: 'The audio exceeds this step; adjust its range, offset, or step hold. It will not cross steps automatically.' },
|
|
21
|
+
}
|
|
22
|
+
export function productionAudioMessage(doc: IRDocument, code: string): string { return (messages[code] ?? messages['production.asset-format']!)[deckLocale(doc.meta.lang)] }
|
|
23
|
+
|
|
24
|
+
/** Media never retimes the lecture. Every use must fit an existing confirmed step range. */
|
|
25
|
+
export function placeProductionAudio(doc: IRDocument, pages: readonly ProductionPage[], assets: ReadonlyMap<string, ProductionAssetInspection>, errors: ReadonlyMap<string, string>): { clips: ProductionAudioClip[], issues: ProductionAudioIssue[] } {
|
|
26
|
+
const clips: ProductionAudioClip[] = [], issues: ProductionAudioIssue[] = []
|
|
27
|
+
for (const clip of doc.production?.mediaClips ?? []) {
|
|
28
|
+
const page = pages.find(page => page.steps.some(step => step.stepId === clip.attachment.stepId))
|
|
29
|
+
const step = page?.steps.find(step => step.stepId === clip.attachment.stepId), asset = assets.get(clip.assetId)
|
|
30
|
+
const issue = (code: string) => issues.push({ code, ...page ? { sectionId: page.sectionId } : {}, stepId: clip.attachment.stepId, assetId: clip.assetId, mediaClipId: clip.id })
|
|
31
|
+
if (!asset) { issue(errors.get(clip.assetId) ?? 'production.asset-missing'); continue }
|
|
32
|
+
if (clip.sourceRange.startMs < 0 || clip.sourceRange.endMs <= clip.sourceRange.startMs || clip.sourceRange.endMs > asset.durationMs) { issue('production.media-range'); continue }
|
|
33
|
+
if (!step || step.timing !== 'confirmed' || step.startMs === undefined || step.endMs === undefined) { issue('production.media-step-unknown'); continue }
|
|
34
|
+
const duration = clip.sourceRange.endMs - clip.sourceRange.startMs
|
|
35
|
+
if (clip.attachment.offsetMs < 0 || clip.attachment.offsetMs + duration > step.endMs - step.startMs) { issue('production.media-step-range'); continue }
|
|
36
|
+
clips.push({ id: clip.id, assetId: clip.assetId, mediaId: asset.mediaId, sectionId: page!.sectionId, stepId: step.stepId, startMs: step.startMs + clip.attachment.offsetMs, endMs: step.startMs + clip.attachment.offsetMs + duration, sourceRange: { ...clip.sourceRange }, volume: clip.volume, muted: clip.muted ?? false })
|
|
37
|
+
}
|
|
38
|
+
return { clips, issues }
|
|
39
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { deckLocale } from '@lincyaw/kernel'
|
|
2
|
+
import type { IRDocument, ProductionAssetInspection, ProductionAudio, ProductionDiagnostic, ProductionPage, ProductionPlan, ProductionRange, ProductionSegment, ProductionTiming } from '@lincyaw/kernel'
|
|
3
|
+
import { plannedAnimations, plannedUtterances } from './plan.ts'
|
|
4
|
+
import type { LecturePlan } from './plan.ts'
|
|
5
|
+
import { trackDuration } from './track.ts'
|
|
6
|
+
import { captionTimingSources } from './caption-timing.ts'
|
|
7
|
+
import { captionGroups } from './captions.ts'
|
|
8
|
+
import type { LectureTrack } from './track.ts'
|
|
9
|
+
|
|
10
|
+
const messages = {
|
|
11
|
+
'zh-CN': {
|
|
12
|
+
animation: '动画未声明有效时长,不能用于按时播放或导出。',
|
|
13
|
+
recording: '录音中的提示位置或讲稿起点尚无确认的时间;请使用不跨步、无内部停顿且起点一致的讲稿。',
|
|
14
|
+
recordingCaptions: '配音已裁切;字幕仍为本步完整讲稿,未按原音对齐,请核对。',
|
|
15
|
+
fingerprint: '采用此原音前须确认实际 SHA-256。',
|
|
16
|
+
media: '当前只支持同文稿本地音频;视频尚未支持。',
|
|
17
|
+
cue: '无配音讲稿中的文字位置提示没有确认的时间;请移到步骤起点或提供音频。',
|
|
18
|
+
next: '含跨步提示的同一段配音尚不能插入步骤结束停顿;请先拆为步骤绑定的讲稿。',
|
|
19
|
+
cache: '当前讲稿与音色缺少已生成的配音;显示的时长为估算,不能用于确认播放或导出。',
|
|
20
|
+
unset: '此讲稿尚未选择音频来源;请选择无配音或实际音色。',
|
|
21
|
+
step: (step: number) => `第 ${step} 步`,
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
animation: 'The animation has no valid declared duration; timed playback and export are unavailable.',
|
|
25
|
+
recording: 'Recording cues or script starts have no confirmed timing; use step-bound scripts without internal pauses and with one common start.',
|
|
26
|
+
recordingCaptions: 'The voiceover is trimmed. Captions still display the complete step script without audio alignment; review them.',
|
|
27
|
+
fingerprint: 'Confirm the actual SHA-256 before adopting this recording.',
|
|
28
|
+
media: 'Only local audio from this document is supported; video is unavailable.',
|
|
29
|
+
cue: 'Text-anchored cues without audio have no confirmed timing; move them to the step start or provide audio.',
|
|
30
|
+
next: 'A voiceover clip with a next-step cue can’t insert trailing step pauses yet; split it into step-bound scripts first.',
|
|
31
|
+
cache: 'No generated voiceover matches the current script and voice; the displayed duration is estimated and can’t confirm playback or export.',
|
|
32
|
+
unset: 'This script has no audio source; choose silence or an actual voice.',
|
|
33
|
+
step: (step: number) => `Step ${step}`,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
export function productionMessages(doc: IRDocument) { return messages[deckLocale(doc.meta.lang)] }
|
|
37
|
+
|
|
38
|
+
/** Capability findings shared by the renderer and the read-only production plan. */
|
|
39
|
+
export function productionDiagnostics(doc: IRDocument, plan: LecturePlan): ProductionDiagnostic[] {
|
|
40
|
+
const result: ProductionDiagnostic[] = [], text = productionMessages(doc)
|
|
41
|
+
for (const clip of doc.production?.mediaClips ?? []) {
|
|
42
|
+
const section = doc.sections.find(section => section.stepIds?.includes(clip.attachment.stepId))
|
|
43
|
+
if (doc.productionAssets?.[clip.assetId]?.kind !== 'audio') result.push({ code: 'production.media-unsupported', severity: 'error', message: text.media, ...section ? { sectionId: section.id } : {}, stepId: clip.attachment.stepId })
|
|
44
|
+
}
|
|
45
|
+
for (const section of doc.sections) {
|
|
46
|
+
for (const animation of plannedAnimations(section)) if (animation.durationMs === undefined) result.push({ code: 'production.animation-duration-unknown', severity: 'error', message: text.animation, sectionId: section.id, stepId: section.stepIds?.[animation.step], blockId: animation.blockId })
|
|
47
|
+
const own = plan.sections.find(own => own.id === section.id)
|
|
48
|
+
if (!own) continue
|
|
49
|
+
if (own.trailingPauseMs && own.paragraphs.some(paragraph => paragraph.pieces.some(piece => piece.cues.some(cue => cue.name === 'next')))) result.push({ code: 'production.next-pause-unsupported', severity: 'error', message: text.next, sectionId: section.id })
|
|
50
|
+
for (const [index, stepId] of (section.stepIds ?? []).entries()) {
|
|
51
|
+
const source = doc.production?.stepOverrides[stepId]?.voiceSource ?? doc.production?.sectionOverrides?.[section.id]?.voiceSource ?? doc.production?.defaults.voiceSource
|
|
52
|
+
if (source?.type === 'recording') {
|
|
53
|
+
const recording = own.recordings?.find(recording => recording.stepId === stepId), asset = doc.productionAssets?.[source.assetId]
|
|
54
|
+
if (recording?.issue) result.push({ code: `production.${recording.issue}`, severity: 'error', message: text.recording, sectionId: section.id, stepId, assetId: source.assetId })
|
|
55
|
+
if (!asset?.fingerprint || !/^(?:sha256:)?[a-f\d]{64}$/i.test(asset.fingerprint)) result.push({ code: 'production.recording-fingerprint-required', severity: 'error', message: text.fingerprint, sectionId: section.id, stepId, assetId: source.assetId })
|
|
56
|
+
}
|
|
57
|
+
for (const paragraph of own.paragraphs.filter(paragraph => paragraph.step === index)) if (source?.type === 'none' && paragraph.pieces.some(piece => piece.cues.some(cue => cue.name !== 'pause' && cue.offset > 0))) result.push({ code: 'production.silent-cue-unknown', severity: 'error', message: text.cue, sectionId: section.id, stepId, narrationId: paragraph.narrationId })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function projectProductionPlan(doc: IRDocument, plan: LecturePlan, track: LectureTrack, audio: ReadonlyMap<string, ProductionAudio>, failures: readonly { id: string, section: string, text: string, reason: string }[], mediaDiagnostics: readonly ProductionDiagnostic[] = [], assets: ReadonlyMap<string, ProductionAssetInspection> = new Map()): Omit<ProductionPlan, 'binding'> {
|
|
64
|
+
const duration = trackDuration(track), units = plannedUtterances(plan), byId = new Map(units.map(unit => [unit.id, unit]))
|
|
65
|
+
const diagnostics: ProductionDiagnostic[] = [...productionDiagnostics(doc, plan), ...mediaDiagnostics]
|
|
66
|
+
const messages = productionMessages(doc)
|
|
67
|
+
for (const failure of failures) {
|
|
68
|
+
const unit = byId.get(failure.id), section = doc.sections.find(section => section.id === failure.section)
|
|
69
|
+
diagnostics.push({ code: unit?.sourceKind === 'unset' ? 'production.voice-unset' : 'production.cache-missing', severity: 'warning', message: unit?.sourceKind === 'unset' ? messages.unset : messages.cache, sectionId: failure.section, ...unit ? { stepId: section?.stepIds?.[unit.step], narrationId: unit.narrationId } : {} })
|
|
70
|
+
}
|
|
71
|
+
for (const finding of plan.problems) diagnostics.push({ code: 'production.cue-invalid', severity: 'warning', message: finding.message })
|
|
72
|
+
const starts = track.events.flatMap(([at, type, data]) => type === 'go' && typeof data.section === 'string' && typeof data.step === 'number' ? [{ at, section: data.section, step: data.step }] : [])
|
|
73
|
+
const startFor = (section: string, step: number) => starts.find(at => at.section === section && at.step === step)?.at ?? 0
|
|
74
|
+
const unknown = diagnostics.filter(issue => issue.severity === 'error')
|
|
75
|
+
const unknownAt = unknown.length ? Math.min(...unknown.map(issue => {
|
|
76
|
+
const section = doc.sections.find(section => section.id === issue.sectionId)
|
|
77
|
+
return section ? startFor(section.id, issue.stepId ? section.stepIds?.indexOf(issue.stepId) ?? 0 : 0) : 0
|
|
78
|
+
})) : Infinity
|
|
79
|
+
const estimatedAt = Math.min(Infinity, ...track.utterances.filter(utterance => utterance.unsaid).map(utterance => utterance.t))
|
|
80
|
+
const range = (start: number, end: number): ProductionRange => {
|
|
81
|
+
if (start >= unknownAt || end > unknownAt) return { ...start < unknownAt ? { startMs: start } : {}, timing: 'unknown' }
|
|
82
|
+
return { startMs: start, endMs: end, timing: end > estimatedAt || start >= estimatedAt ? 'estimated' : 'confirmed' }
|
|
83
|
+
}
|
|
84
|
+
const pages: ProductionPage[] = doc.sections.map(section => {
|
|
85
|
+
const planned = plan.sections.find(planned => planned.id === section.id), animations = plannedAnimations(section)
|
|
86
|
+
const steps = (section.stepIds ?? []).map((stepId, index) => {
|
|
87
|
+
const start = startFor(section.id, index), at = starts.findIndex(at => at.section === section.id && at.step === index)
|
|
88
|
+
const end = at < 0 ? start : starts[at + 1]?.at ?? duration
|
|
89
|
+
const own = animations.filter(animation => animation.step === index)
|
|
90
|
+
return { stepId, index, ...range(start, end), holdMs: planned?.holds[index] ?? 0, ...own.every(animation => animation.durationMs !== undefined) ? { animationMs: Math.max(0, ...own.map(animation => animation.durationMs!)) } : {} }
|
|
91
|
+
})
|
|
92
|
+
const start = startFor(section.id, 0), last = steps.at(-1)
|
|
93
|
+
return { sectionId: section.id, title: section.title, ...range(start, last?.endMs ?? duration), ...last?.endMs === undefined && last?.timing === 'unknown' ? { endMs: undefined, timing: 'unknown' as const } : {}, steps }
|
|
94
|
+
})
|
|
95
|
+
const captionSources = captionTimingSources(doc, track, pages)
|
|
96
|
+
for (const page of pages) for (const step of page.steps) step.captionTiming = captionSources.get(step.stepId)
|
|
97
|
+
const visual: ProductionSegment[] = pages.flatMap(page => page.steps.map(step => ({ id: `visual:${step.stepId}`, sectionId: page.sectionId, stepId: step.stepId, label: messages.step(step.index + 1), startMs: step.startMs, endMs: step.endMs, timing: step.timing })))
|
|
98
|
+
const voice: ProductionSegment[] = [], caption: ProductionSegment[] = []
|
|
99
|
+
for (const utterance of track.utterances) {
|
|
100
|
+
const section = doc.sections.find(section => section.id === utterance.section), stepId = section?.stepIds?.[utterance.step ?? 0]
|
|
101
|
+
if (!section || !stepId) continue
|
|
102
|
+
const unit = byId.get(utterance.id), file = utterance.clipId ? audio.get(utterance.clipId) : undefined, groups = captionGroups(utterance)
|
|
103
|
+
const covered = pages.find(page => page.sectionId === section.id)?.steps.filter(step => step.startMs !== undefined && step.endMs !== undefined && step.startMs < utterance.t + utterance.d && step.endMs > utterance.t).map(step => step.stepId) ?? []
|
|
104
|
+
const segment: ProductionSegment = {
|
|
105
|
+
id: utterance.id, sectionId: section.id, stepId, stepIds: [...new Set([stepId, ...covered])], ...unit ? { sourceStepId: section.stepIds?.[unit.step] } : {},
|
|
106
|
+
...utterance.narrationIds ? { narrationIds: [...utterance.narrationIds] } : {}, utteranceId: utterance.id, label: utterance.text,
|
|
107
|
+
...range(utterance.t, utterance.t + utterance.d), status: utterance.recording ? 'recorded' : file ? 'cached' : utterance.silent ? 'silent' : unit?.sourceKind === 'recording' || unit?.sourceKind === 'unset' ? 'blocked' : 'missing',
|
|
108
|
+
alignment: groups[0]?.alignment ?? 'none',
|
|
109
|
+
}
|
|
110
|
+
if (!utterance.recording) voice.push({ ...segment, id: `voice:${utterance.id}`, ...file ? { audio: file } : {} })
|
|
111
|
+
for (const group of track.captionOverrides?.some(own => own.section === utterance.section && own.step === utterance.step) ? [] : groups) {
|
|
112
|
+
const covered = pages.find(page => page.sectionId === section.id)?.steps.filter(step => step.startMs !== undefined && step.endMs !== undefined && step.startMs < group.t + group.d && step.endMs > group.t).map(step => step.stepId) ?? []
|
|
113
|
+
caption.push({ ...segment, id: `caption:${group.id}`, stepId: covered[0] ?? stepId, stepIds: covered.length ? covered : [stepId], label: group.text,
|
|
114
|
+
...range(group.t, group.t + group.d), alignment: group.alignment })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const own of track.captionOverrides ?? []) for (const group of own.groups) caption.push({ id: `caption:${group.id}`, sectionId: own.section, stepId: own.stepId, stepIds: [own.stepId], label: group.text, alignment: 'manual', status: track.audio?.some(a => a.role === 'recording' && a.stepId === own.stepId) ? 'recorded' : 'cached', ...range(group.t, group.t + group.d) })
|
|
118
|
+
for (const segment of track.audio ?? []) {
|
|
119
|
+
if (segment.role === 'recording' && segment.assetId && segment.stepId) {
|
|
120
|
+
const media = assets.get(segment.assetId)
|
|
121
|
+
if (media) {
|
|
122
|
+
const sourceRange = { startMs: segment.sourceStartMs ?? 0, endMs: (segment.sourceStartMs ?? 0) + segment.duration * 1000 }
|
|
123
|
+
voice.push({ id: `voice:recording:${segment.stepId}`, sectionId: segment.section, stepId: segment.stepId, stepIds: [segment.stepId], narrationIds: segment.narrationIds ?? [], sourceStepId: segment.stepId, assetId: segment.assetId, media, label: doc.productionAssets?.[segment.assetId]?.title || segment.assetId, status: 'recorded', alignment: 'none', sourceRange, ...range(segment.start * 1000, (segment.start + segment.duration) * 1000) })
|
|
124
|
+
if ((sourceRange.startMs > 0 || sourceRange.endMs < media.durationMs) && track.utterances.some(utterance => utterance.recording && utterance.section === segment.section && utterance.t === segment.start * 1000)) diagnostics.push({ code: 'production.recording-captions-unverified', severity: 'warning', message: messages.recordingCaptions, sectionId: segment.section, stepId: segment.stepId, assetId: segment.assetId })
|
|
125
|
+
}
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (!segment.assetId || !segment.mediaClipId) continue
|
|
129
|
+
const clip = doc.production?.mediaClips.find(clip => clip.id === segment.mediaClipId), media = assets.get(segment.assetId)
|
|
130
|
+
if (!clip || !media) continue
|
|
131
|
+
voice.push({ id: `media:${clip.id}`, sectionId: segment.section, stepId: clip.attachment.stepId, stepIds: [clip.attachment.stepId], mediaClipId: clip.id, assetId: clip.assetId, label: doc.productionAssets?.[clip.assetId]?.title || clip.assetId, media, sourceRange: { ...clip.sourceRange }, volume: clip.volume, muted: clip.muted ?? false, alignment: 'none', ...range(segment.start * 1000, (segment.start + segment.duration) * 1000) })
|
|
132
|
+
}
|
|
133
|
+
const timing: ProductionTiming = unknown.length ? 'unknown' : Number.isFinite(estimatedAt) ? 'estimated' : 'confirmed'
|
|
134
|
+
const ready = timing === 'confirmed' && failures.length === 0 && duration > 0
|
|
135
|
+
return { ...timing !== 'unknown' ? { durationMs: duration } : {}, timing, pages, tracks: [{ kind: 'visual', segments: visual }, { kind: 'voice', segments: voice }, { kind: 'caption', segments: caption }], events: timing === 'unknown' ? [] : track.events, diagnostics, canPreview: timing !== 'unknown' && duration > 0, canExport: ready }
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { TrackEvent } from '@lincyaw/kernel'
|
|
2
|
+
import { blockOf } from './drift.ts'
|
|
3
|
+
import { timedEvents } from './timing.ts'
|
|
4
|
+
import type { LectureTrack, MediaSegment, TrackBlock, Utterance, VideoSegment, Voice } from './track.ts'
|
|
5
|
+
import { sortEvents, TRACK_VERSION, trackDuration } from './track.ts'
|
|
6
|
+
|
|
7
|
+
export type PathPoint = [dt: number, x: number, y: number]
|
|
8
|
+
|
|
9
|
+
function distance([, x, y]: PathPoint, [, x1, y1]: PathPoint, [, x2, y2]: PathPoint): number {
|
|
10
|
+
const dx = x2 - x1
|
|
11
|
+
const dy = y2 - y1
|
|
12
|
+
const length = dx * dx + dy * dy
|
|
13
|
+
if (length === 0) return Math.hypot(x - x1, y - y1)
|
|
14
|
+
const k = Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / length))
|
|
15
|
+
return Math.hypot(x - (x1 + k * dx), y - (y1 + k * dy))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function simplifyPath(path: readonly PathPoint[], tolerance = 0.01): PathPoint[] {
|
|
19
|
+
const round = ([t, x, y]: PathPoint): PathPoint => [Math.round(t), Math.round(x * 1000) / 1000, Math.round(y * 1000) / 1000]
|
|
20
|
+
if (path.length <= 2) return path.map(round)
|
|
21
|
+
const keep = new Set([0, path.length - 1])
|
|
22
|
+
const split = (from: number, to: number) => {
|
|
23
|
+
let farthest = -1
|
|
24
|
+
let worst = tolerance
|
|
25
|
+
for (let i = from + 1; i < to; i++) {
|
|
26
|
+
const d = distance(path[i]!, path[from]!, path[to]!)
|
|
27
|
+
if (d > worst) {
|
|
28
|
+
worst = d
|
|
29
|
+
farthest = i
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (farthest === -1) return
|
|
33
|
+
keep.add(farthest)
|
|
34
|
+
split(from, farthest)
|
|
35
|
+
split(farthest, to)
|
|
36
|
+
}
|
|
37
|
+
split(0, path.length - 1)
|
|
38
|
+
return [...keep].sort((a, b) => a - b).map(i => round(path[i]!))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Recording {
|
|
42
|
+
events: TrackEvent[]
|
|
43
|
+
audio: MediaSegment[]
|
|
44
|
+
video: VideoSegment[]
|
|
45
|
+
utterances: Utterance[]
|
|
46
|
+
duration: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TrackHead {
|
|
50
|
+
id: string
|
|
51
|
+
doc: string
|
|
52
|
+
lang: string
|
|
53
|
+
irHash: string
|
|
54
|
+
sections: Record<string, string>
|
|
55
|
+
voice: Voice
|
|
56
|
+
blocks: Record<string, TrackBlock>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function recordedTrack(head: TrackHead, recording: Recording): LectureTrack {
|
|
60
|
+
return {
|
|
61
|
+
track: TRACK_VERSION,
|
|
62
|
+
id: head.id,
|
|
63
|
+
doc: head.doc,
|
|
64
|
+
lang: head.lang,
|
|
65
|
+
ir: { hash: head.irHash, sections: head.sections },
|
|
66
|
+
voice: head.voice,
|
|
67
|
+
...recording.audio.length ? { audio: recording.audio } : {},
|
|
68
|
+
...recording.video.length ? { video: recording.video } : {},
|
|
69
|
+
blocks: pruneBlocks(head.blocks, recording.events),
|
|
70
|
+
utterances: recording.utterances,
|
|
71
|
+
events: sortEvents(recording.events),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pruneBlocks(blocks: Record<string, TrackBlock>, events: readonly TrackEvent[]): Record<string, TrackBlock> {
|
|
76
|
+
const used = new Set(events.flatMap(([, , data]) => blockOf(data) ?? []))
|
|
77
|
+
return Object.fromEntries(Object.entries(blocks).filter(([key]) => used.has(key)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sectionSpan(track: LectureTrack, section: string, end: number): [number, number] | undefined {
|
|
81
|
+
const goes = timedEvents(track).filter(e => e.type === 'go' && typeof e.data.section === 'string')
|
|
82
|
+
const first = goes.findIndex(e => e.data.section === section)
|
|
83
|
+
if (first === -1) return undefined
|
|
84
|
+
const next = goes.slice(first + 1).find(e => e.data.section !== section)
|
|
85
|
+
return [goes[first]!.t, next?.t ?? end]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splicedIr(track: LectureTrack, section: string, head: TrackHead): LectureTrack['ir'] {
|
|
89
|
+
const before = track.ir.sections ?? (track.ir.hash === head.irHash ? head.sections : {})
|
|
90
|
+
const unknown = Object.fromEntries(Object.keys(head.sections).filter(id => !(id in before)).map(id => [id, '']))
|
|
91
|
+
const sections = { ...unknown, ...before, ...section in head.sections ? { [section]: head.sections[section]! } : {} }
|
|
92
|
+
const current = Object.entries(sections).every(([id, hash]) => head.sections[id] === hash)
|
|
93
|
+
return { hash: current ? head.irHash : track.ir.hash, sections }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function spliceSection(track: LectureTrack, section: string, recording: Recording, head: TrackHead): { track: LectureTrack, unused: string[] } {
|
|
97
|
+
const end = Math.round(trackDuration(track))
|
|
98
|
+
const [from, to] = sectionSpan(track, section, end) ?? [end, end]
|
|
99
|
+
const shift = recording.duration - (to - from)
|
|
100
|
+
const inside = (t: number) => t >= from && t < to
|
|
101
|
+
const moved = (t: number) => (t >= to ? t + shift : t)
|
|
102
|
+
const keepMedia = <T extends MediaSegment>(segments: T[] = []) => segments
|
|
103
|
+
.filter(s => !(s.section === section && inside(s.start * 1000)))
|
|
104
|
+
.map(s => ({ ...s, start: Math.round(moved(s.start * 1000)) / 1000 }))
|
|
105
|
+
const offset = <T extends MediaSegment>(segments: T[]) => segments.map(s => ({ ...s, start: Math.round(s.start * 1000 + from) / 1000 }))
|
|
106
|
+
const removed = new Set(track.utterances.filter(u => inside(u.t)).map(u => u.id))
|
|
107
|
+
const events: TrackEvent[] = [
|
|
108
|
+
...track.events.filter(([t]) => !inside(t)).map(([t, type, data]): TrackEvent => [moved(t), type, data]),
|
|
109
|
+
...recording.events.map(([t, type, data]): TrackEvent => [t + from, type, data]),
|
|
110
|
+
]
|
|
111
|
+
const audio = [...keepMedia(track.audio), ...offset(recording.audio)].sort((a, b) => a.start - b.start)
|
|
112
|
+
const video = [...keepMedia(track.video), ...offset(recording.video)].sort((a, b) => a.start - b.start)
|
|
113
|
+
const kept = new Set([...audio, ...video].map(s => s.src))
|
|
114
|
+
const unused = [...track.audio ?? [], ...track.video ?? []].map(s => s.src).filter(src => !kept.has(src))
|
|
115
|
+
const next: LectureTrack = {
|
|
116
|
+
...track,
|
|
117
|
+
ir: splicedIr(track, section, head),
|
|
118
|
+
...audio.length ? { audio } : { audio: undefined },
|
|
119
|
+
...video.length ? { video } : { video: undefined },
|
|
120
|
+
blocks: pruneBlocks({ ...track.blocks, ...head.blocks }, events),
|
|
121
|
+
utterances: [
|
|
122
|
+
...track.utterances.filter(u => !removed.has(u.id)).map(u => ({ ...u, t: moved(u.t), words: u.words?.map(([t, d, w]): [number, number, string] => [moved(t), d, w]) })),
|
|
123
|
+
...recording.utterances.map(u => ({ ...u, t: u.t + from, words: u.words?.map(([t, d, w]): [number, number, string] => [t + from, d, w]) })),
|
|
124
|
+
].sort((a, b) => a.t - b.t),
|
|
125
|
+
events: sortEvents(events),
|
|
126
|
+
}
|
|
127
|
+
if (!next.audio) delete next.audio
|
|
128
|
+
if (!next.video) delete next.video
|
|
129
|
+
return { track: next, unused }
|
|
130
|
+
}
|