@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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/presmium-lecture.mjs +12 -0
  4. package/package.json +68 -3
  5. package/src/client/ChapterList.tsx +50 -0
  6. package/src/client/LectureView.css +21 -0
  7. package/src/client/LectureView.tsx +84 -0
  8. package/src/client/PlayLectureButton.tsx +21 -0
  9. package/src/client/PlayerPanel.css +120 -0
  10. package/src/client/PlayerPanel.tsx +194 -0
  11. package/src/client/RecorderPanel.css +46 -0
  12. package/src/client/RecorderPanel.tsx +282 -0
  13. package/src/client/TranscriptList.tsx +28 -0
  14. package/src/client/clock.ts +169 -0
  15. package/src/client/env.d.ts +7 -0
  16. package/src/client/media.ts +86 -0
  17. package/src/client/playback.ts +140 -0
  18. package/src/client/player.ts +202 -0
  19. package/src/client/plugin.ts +33 -0
  20. package/src/client/pointer-ink.ts +143 -0
  21. package/src/client/production-playback.ts +188 -0
  22. package/src/client/recorder.ts +245 -0
  23. package/src/client/source.ts +109 -0
  24. package/src/client/state.ts +45 -0
  25. package/src/client/viewer.ts +37 -0
  26. package/src/domain/audience.ts +108 -0
  27. package/src/domain/caption-timing.ts +59 -0
  28. package/src/domain/captions.ts +59 -0
  29. package/src/domain/config.ts +67 -0
  30. package/src/domain/drift.ts +159 -0
  31. package/src/domain/editing.ts +112 -0
  32. package/src/domain/format.ts +13 -0
  33. package/src/domain/index.ts +50 -0
  34. package/src/domain/messages.ts +324 -0
  35. package/src/domain/narration.ts +160 -0
  36. package/src/domain/plan.ts +319 -0
  37. package/src/domain/production-audio.ts +39 -0
  38. package/src/domain/production.ts +136 -0
  39. package/src/domain/recording.ts +130 -0
  40. package/src/domain/script-track.ts +176 -0
  41. package/src/domain/timing.ts +107 -0
  42. package/src/domain/track.ts +302 -0
  43. package/src/domain/translation.ts +34 -0
  44. package/src/domain/vtt.ts +68 -0
  45. package/src/domain/words.ts +114 -0
  46. package/src/domain/writeback.ts +129 -0
  47. package/src/node/audio-authoring.ts +53 -0
  48. package/src/node/check.ts +93 -0
  49. package/src/node/cli.ts +82 -0
  50. package/src/node/clips.ts +10 -0
  51. package/src/node/engines.ts +43 -0
  52. package/src/node/export.ts +331 -0
  53. package/src/node/ffmpeg.ts +76 -0
  54. package/src/node/latest-ir.ts +54 -0
  55. package/src/node/plan.ts +35 -0
  56. package/src/node/presmium.ts +59 -0
  57. package/src/node/production-assets.ts +112 -0
  58. package/src/node/production.ts +102 -0
  59. package/src/node/renderer.ts +182 -0
  60. package/src/node/roles.ts +23 -0
  61. package/src/node/routes/production.ts +51 -0
  62. package/src/node/routes/recordings.ts +122 -0
  63. package/src/node/routes/serve-file.ts +38 -0
  64. package/src/node/routes/served-deck.ts +61 -0
  65. package/src/node/routes/server.ts +12 -0
  66. package/src/node/routes/tracks.ts +98 -0
  67. package/src/node/routes/voiceover.ts +23 -0
  68. package/src/node/store.ts +192 -0
  69. package/src/node/synthesize.ts +190 -0
  70. package/src/node/tools.ts +170 -0
  71. package/src/node/transcribe.ts +53 -0
  72. package/src/node/voice-generation.ts +15 -0
  73. package/src/node/voice-source.ts +34 -0
  74. package/src/node/voiceover-execution.ts +223 -0
@@ -0,0 +1,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
+ }