@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +153 -0
  10. package/src/client/PlayerPanel.tsx +198 -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 +179 -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,109 @@
1
+ import { siteBase } from '@lincyaw/host'
2
+ import type { ServerClient } from '@lincyaw/host'
3
+ import { ABSOLUTE_URL, isRecord } from '@lincyaw/kernel'
4
+ import type { ProductionBinding, ProductionPlan, ProductionPlanQuery } from '@lincyaw/kernel'
5
+ import { LECTURE_ID, parseTrack } from '../domain/index.ts'
6
+ import type { LectureTrack, Voice } from '../domain/index.ts'
7
+
8
+ export interface LectureSummary {
9
+ id: string
10
+ lang?: string
11
+ voice?: Voice
12
+ duration?: number
13
+ }
14
+
15
+ export interface LectureSource {
16
+ list(): Promise<LectureSummary[]>
17
+ track(id: string, planId?: string, signal?: AbortSignal): Promise<LectureTrack | undefined>
18
+ media(id: string, src: string): Promise<string>
19
+ plan?(query: ProductionPlanQuery, signal?: AbortSignal): Promise<ProductionPlan>
20
+ productionMedia?(binding: ProductionBinding, clipId: string, signal?: AbortSignal): Promise<string>
21
+ }
22
+
23
+ function summaries(value: unknown): LectureSummary[] {
24
+ return Array.isArray(value) ? value.filter((v): v is LectureSummary => isRecord(v) && typeof v.id === 'string') : []
25
+ }
26
+
27
+ /** Why the server refused: its `{ error }`, or the text it answered (a proxy's), else the status. */
28
+ async function refusal(response: Response): Promise<string> {
29
+ const text = (await response.text().catch(() => '')).trim()
30
+ let body: unknown
31
+ try {
32
+ body = JSON.parse(text)
33
+ } catch {}
34
+ if (isRecord(body) && typeof body.error === 'string') return body.error
35
+ return text && !text.startsWith('<') ? `HTTP ${response.status}: ${text.slice(0, 300)}` : `HTTP ${response.status}`
36
+ }
37
+
38
+ async function readTrackAnswer(response: Response): Promise<LectureTrack | undefined> {
39
+ if (response.status === 404) return undefined
40
+ if (!response.ok) throw new Error(await refusal(response))
41
+ const { track, problems } = parseTrack(await response.json())
42
+ if (!track) throw new Error(problems.join('; '))
43
+ return track
44
+ }
45
+
46
+ export function serverSource(server: ServerClient): LectureSource {
47
+ return {
48
+ list: async () => summaries(await (await server.fetch(LECTURE_ID, 'tracks')).json()),
49
+ track: async (id, planId, signal) => readTrackAnswer(await server.fetch(LECTURE_ID, `tracks/${encodeURIComponent(id)}${planId ? `?planId=${encodeURIComponent(planId)}` : ''}`, { signal })),
50
+ plan: (query, signal) => readAnswer<ProductionPlan>(server.fetch(LECTURE_ID, `production-plan?source=${encodeURIComponent(JSON.stringify(query.source))}&irRevision=${encodeURIComponent(query.irRevision)}`, { signal })),
51
+ async productionMedia(binding, clipId, signal) {
52
+ const query = `source=${encodeURIComponent(JSON.stringify(binding.source))}&irRevision=${encodeURIComponent(binding.irRevision)}&planId=${encodeURIComponent(binding.planId)}&clipId=${encodeURIComponent(clipId)}`
53
+ const response = await server.fetch(LECTURE_ID, `production-media?${query}`, { signal })
54
+ if (!response.ok) throw new Error(await refusal(response))
55
+ return URL.createObjectURL(await response.blob())
56
+ },
57
+ async media(id, src) {
58
+ if (ABSOLUTE_URL.test(src)) return src
59
+ const response = await server.fetch(LECTURE_ID, `tracks/${encodeURIComponent(id)}/${src.split('/').map(encodeURIComponent).join('/')}`)
60
+ if (!response.ok) throw new Error(`${src}: HTTP ${response.status}`)
61
+ return URL.createObjectURL(await response.blob())
62
+ },
63
+ }
64
+ }
65
+
66
+ export async function readAnswer<T = unknown>(response: Promise<Response>): Promise<T> {
67
+ const r = await response
68
+ const body: unknown = await r.json().catch(() => ({}))
69
+ if (!r.ok) throw new Error(isRecord(body) && typeof body.error === 'string' ? body.error : `HTTP ${r.status}`)
70
+ return body as T
71
+ }
72
+
73
+ /** The lectures published at the first of `bases` that has an index (the last when none answers). */
74
+ export function staticSource(bases: readonly string[]): LectureSource {
75
+ let located: Promise<string> | undefined
76
+ const base = () => located ??= (async () => {
77
+ for (const candidate of bases.slice(0, -1)) {
78
+ const response = await fetch(new URL('index.json', candidate), { method: 'HEAD' }).catch(() => undefined)
79
+ if (response?.ok) return candidate
80
+ }
81
+ return bases.at(-1)!
82
+ })()
83
+ return {
84
+ list: async () => {
85
+ const response = await fetch(new URL('index.json', await base()))
86
+ return response.ok ? summaries(await response.json()) : []
87
+ },
88
+ track: async (id, _planId, signal) => readTrackAnswer(await fetch(new URL(`${encodeURIComponent(id)}/track.json`, await base()), { cache: 'no-cache', signal })),
89
+ media: async (id, src) => (ABSOLUTE_URL.test(src) ? src : new URL(`${encodeURIComponent(id)}/${src.split('/').map(encodeURIComponent).join('/')}`, await base()).href),
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Where a static page looks for its lectures: `presmium.lecture.url` when the document names it; otherwise `lecture/` under
95
+ * the page, where a site of several documents puts each one's lectures in its own directory, then `../lecture/` beside
96
+ * the web form, where a document's own build puts them.
97
+ */
98
+ export function staticBases(config: Record<string, unknown> | undefined): string[] {
99
+ const site = new URL(siteBase(), location.href)
100
+ const configured = typeof config?.url === 'string' && config.url ? config.url : undefined
101
+ return (configured ? [configured] : ['lecture/', '../lecture/']).map((base) => {
102
+ const url = new URL(base, site).href
103
+ return url.endsWith('/') ? url : `${url}/`
104
+ })
105
+ }
106
+
107
+ export function lectureSource(server: ServerClient, config: unknown): LectureSource {
108
+ return server.available ? serverSource(server) : staticSource(staticBases(isRecord(config) ? config : undefined))
109
+ }
@@ -0,0 +1,45 @@
1
+ import { createStore, serverClient, useServer, useStore } from '@lincyaw/host'
2
+ import type { Viewer, ViewerPluginContext } from '@lincyaw/viewer'
3
+ import { useEffect } from 'react'
4
+ import type { LecturePlayer } from './player.ts'
5
+ import { serverSource } from './source.ts'
6
+
7
+ let context: Pick<ViewerPluginContext, 'useViewer'> | undefined
8
+ export function setViewerContext(ctx: Pick<ViewerPluginContext, 'useViewer'>): void { context = ctx }
9
+ export function useViewer(): Viewer {
10
+ if (!context) throw new Error('[presmium] the lecture viewer plugin is not installed')
11
+ return context.useViewer()
12
+ }
13
+ const compiled = createStore(false)
14
+ export const lecturesAvailable = createStore(false)
15
+ export function readLecturesFlag(config: Record<string, unknown>): void {
16
+ compiled.set(config.lectures === true)
17
+ if (!serverClient().available) lecturesAvailable.set(compiled.getSnapshot())
18
+ }
19
+ export async function refreshLectures(): Promise<void> {
20
+ const server = serverClient()
21
+ if (!server.available) { lecturesAvailable.set(compiled.getSnapshot()); return }
22
+ lecturesAvailable.set((await serverSource(server).list().catch(() => [])).length > 0)
23
+ }
24
+ export function watchLectureList(): void { void refreshLectures() }
25
+ export function useLecturesAvailable(): boolean {
26
+ const server = useServer()
27
+ useEffect(() => { void refreshLectures() }, [server.available])
28
+ return useStore(lecturesAvailable)
29
+ }
30
+ const PARAM = 'track'
31
+ function fromUrl(): string | undefined { return typeof location === 'undefined' ? undefined : new URLSearchParams(location.search).get(PARAM) ?? undefined }
32
+ export const playing = createStore<string | undefined>(fromUrl())
33
+ export const exporting = typeof location !== 'undefined' && new URLSearchParams(location.search).has('export')
34
+ /** `&autoplay`: a page that embeds the presentation asks for the lecture to start as soon as it is ready. */
35
+ export const autoplay = typeof location !== 'undefined' && new URLSearchParams(location.search).has('autoplay')
36
+ export const burnedCaptions = exporting ? new URLSearchParams(location.search).get('captions') ?? undefined : undefined
37
+ export function play(id: string | undefined): void {
38
+ playing.set(id)
39
+ const url = new URL(location.href)
40
+ if (id) url.searchParams.set(PARAM, id)
41
+ else url.searchParams.delete(PARAM)
42
+ history.replaceState(history.state, '', url.pathname + url.search + url.hash)
43
+ }
44
+ /** The player a presentation shows, which Space plays and pauses; none while Video Production drives the player. */
45
+ export const keyboardPlayer = createStore<LecturePlayer | undefined>(undefined)
@@ -0,0 +1,37 @@
1
+ import type { ViewerPlugin } from '@lincyaw/viewer'
2
+ import { currentLocale } from '@lincyaw/host'
3
+ import { LECTURE_MESSAGES } from '../domain/index.ts'
4
+ import LectureView from './LectureView.tsx'
5
+ import PlayerPanel from './PlayerPanel.tsx'
6
+ import PlayLectureButton from './PlayLectureButton.tsx'
7
+ import RecorderPanel from './RecorderPanel.tsx'
8
+ import { exporting, keyboardPlayer, setViewerContext, watchLectureList } from './state.ts'
9
+
10
+ const plugin: ViewerPlugin = (ctx) => {
11
+ setViewerContext(ctx)
12
+ watchLectureList()
13
+ if (exporting) document.documentElement.classList.add('pm-lecture-exporting')
14
+ ctx.registerView({
15
+ id: 'lecture',
16
+ get label() {
17
+ return LECTURE_MESSAGES[currentLocale()].view
18
+ },
19
+ kind: 'overlay',
20
+ component: LectureView,
21
+ // Kept for lecture links and choosing a recorded track; playback is part of the presentation's controls.
22
+ hidden: true,
23
+ })
24
+ ctx.registerLayer({ id: 'presmium.lecture.player', component: PlayerPanel, views: ['present'], scope: exporting ? 'view' : 'bar' })
25
+ // Space plays and pauses the lecture; without a player it is left to the next step.
26
+ ctx.registerAction({ id: 'presmium.lecture.play', keys: [' '], views: ['present'], run: (_viewer, event) => {
27
+ const player = keyboardPlayer.getSnapshot()
28
+ if (!player) return false
29
+ if (event?.repeat) return true
30
+ if (player.playing) player.pause()
31
+ else void player.play()
32
+ } })
33
+ ctx.registerLayer({ id: 'presmium.lecture.play', component: PlayLectureButton, views: ['present'], scope: 'bar' })
34
+ ctx.registerLayer({ id: 'presmium.lecture.recorder', component: RecorderPanel, views: ['present'], scope: 'view' })
35
+ }
36
+
37
+ export default plugin
@@ -0,0 +1,108 @@
1
+ import type { IRDocument, TrackEvent } from '@lincyaw/kernel'
2
+ import { blockDrift, blockOf, irHash, sectionHashes } from './drift.ts'
3
+ import { anchorOf, timedEvents } from './timing.ts'
4
+ import type { LectureTrack, MediaSegment, Utterance } from './track.ts'
5
+
6
+ function positionsOf(track: LectureTrack): { sectionAt(t: number): string | undefined, stepAt(t: number): number } {
7
+ const moves = timedEvents(track).filter(event => event.type === 'go' && typeof event.data.section === 'string')
8
+ const at = (t: number) => moves.filter(move => move.t <= t).at(-1)?.data
9
+ return {
10
+ sectionAt(t) {
11
+ const section = at(t)?.section
12
+ return typeof section === 'string' ? section : undefined
13
+ },
14
+ stepAt: t => Number(at(t)?.step ?? 0),
15
+ }
16
+ }
17
+
18
+ export function trimToDeck(track: LectureTrack, doc: IRDocument): LectureTrack | undefined {
19
+ const sections = new Set(doc.sections.map(section => section.id))
20
+ const drift = blockDrift(track, doc)
21
+ const keptStep = (section: string | undefined, step: number) => (section === undefined || sections.has(section) ? step : undefined)
22
+ const keepsBlock = (key: string) => sections.has(key.slice(0, key.lastIndexOf('/'))) || Boolean(drift.get(key)?.to)
23
+ const { sectionAt, stepAt } = positionsOf(track)
24
+
25
+ let changed = false
26
+ const tainted = new Set<string>()
27
+ const dropped = new Set<string>()
28
+ const utterances: Utterance[] = []
29
+ for (const u of track.utterances) {
30
+ const section = u.section ?? sectionAt(u.t)
31
+ const step = keptStep(section, u.step ?? stepAt(u.t))
32
+ if (step === undefined) {
33
+ dropped.add(u.id)
34
+ if (section) tainted.add(section)
35
+ continue
36
+ }
37
+ if (u.step !== undefined && step !== u.step) changed = true
38
+ utterances.push(u.step === undefined || step === u.step ? u : { ...u, step })
39
+ }
40
+ const events: TrackEvent[] = []
41
+ for (const [t, type, data] of track.events) {
42
+ const where = sectionAt(t)
43
+ if (type === 'go') {
44
+ const section = typeof data.section === 'string' ? data.section : undefined
45
+ const step = keptStep(section, Number(data.step ?? 0))
46
+ if (step !== undefined) {
47
+ if (data.step !== undefined && step !== data.step) changed = true
48
+ events.push([t, type, data.step === undefined || step === data.step ? data : { ...data, step }])
49
+ continue
50
+ }
51
+ for (const name of [section, where]) if (name) tainted.add(name)
52
+ continue
53
+ }
54
+ const block = blockOf(data)
55
+ const anchor = anchorOf(data)
56
+ const placed = keptStep(where, stepAt(t)) !== undefined
57
+ const kept = block ? keepsBlock(block) : placed
58
+ if (kept && !(anchor && dropped.has(anchor[0]))) {
59
+ events.push([t, type, data])
60
+ continue
61
+ }
62
+ for (const name of [block?.slice(0, block.lastIndexOf('/')), where]) if (name) tainted.add(name)
63
+ }
64
+ if (utterances.length === 0 && events.length === 0) return undefined
65
+ const media = <T extends MediaSegment>(segments: T[] | undefined) => segments?.filter(segment => sections.has(segment.section) && !tainted.has(segment.section))
66
+ const audio = media(track.audio)
67
+ const video = media(track.video)
68
+ const captionOverrides = track.captionOverrides?.filter(own => sections.has(own.section) && !tainted.has(own.section))
69
+ const irSections = track.ir.sections && Object.fromEntries(Object.entries(track.ir.sections).filter(([id]) => sections.has(id)))
70
+ const blocks = Object.fromEntries(Object.entries(track.blocks).filter(([key]) => keepsBlock(key)))
71
+ const same = !changed && utterances.length === track.utterances.length && events.length === track.events.length
72
+ && audio?.length === track.audio?.length && video?.length === track.video?.length
73
+ && captionOverrides?.length === track.captionOverrides?.length
74
+ && Object.keys(irSections ?? {}).length === Object.keys(track.ir.sections ?? {}).length && Object.keys(blocks).length === Object.keys(track.blocks).length
75
+ if (same) return track
76
+ return {
77
+ ...track,
78
+ ir: { ...track.ir, ...irSections ? { sections: irSections } : {} },
79
+ ...audio ? { audio } : {},
80
+ ...video ? { video } : {},
81
+ blocks,
82
+ ...captionOverrides ? { captionOverrides } : {},
83
+ utterances,
84
+ events,
85
+ }
86
+ }
87
+
88
+ export function dropChangedSections(track: LectureTrack, view: IRDocument): LectureTrack | undefined {
89
+ const now = sectionHashes(view)
90
+ const recorded = track.ir.sections
91
+ const whole = recorded === undefined && track.ir.hash === irHash(view)
92
+ const same = (section: string | undefined) => section !== undefined && (whole || (recorded?.[section] !== undefined && recorded[section] === now[section]))
93
+ const { sectionAt } = positionsOf(track)
94
+ const utterances = track.utterances.filter(u => same(u.section ?? sectionAt(u.t)))
95
+ const said = new Set(utterances.map(u => u.id))
96
+ const events = track.events.filter(([t, type, data]) => {
97
+ if (type === 'go') return true
98
+ const anchor = anchorOf(data)
99
+ return same(sectionAt(t)) && !(anchor && !said.has(anchor[0]))
100
+ })
101
+ const media = <T extends MediaSegment>(segments: T[] | undefined) => segments?.filter(segment => same(segment.section))
102
+ const audio = media(track.audio)
103
+ const video = media(track.video)
104
+ const captionOverrides = track.captionOverrides?.filter(own => same(own.section))
105
+ if (utterances.length === track.utterances.length && events.length === track.events.length && audio?.length === track.audio?.length && video?.length === track.video?.length && captionOverrides?.length === track.captionOverrides?.length) return track
106
+ if (utterances.length === 0 && !audio?.length && !video?.length) return undefined
107
+ return { ...track, ...audio ? { audio } : {}, ...video ? { video } : {}, ...captionOverrides ? { captionOverrides } : {}, utterances, events }
108
+ }
@@ -0,0 +1,59 @@
1
+ import { captionTimingProblem, deckLocale, fnv1a64 } from '@lincyaw/kernel'
2
+ import type { CaptionTimingSource, IRDocument, ProductionDiagnostic, ProductionPage } from '@lincyaw/kernel'
3
+ import type { LectureTrack } from './track.ts'
4
+
5
+ const STALE = {
6
+ 'zh-CN': '手动字幕时间与当前讲稿、音频或步骤时间不符。请重新核对。',
7
+ 'en': 'Manual caption times no longer match this script, audio, or step timing. Review them before using them again.',
8
+ }
9
+
10
+ /** Actual per-step playback evidence. All positions are relative; no source version or mutable plan ID enters this basis. */
11
+ export function captionTimingSources(doc: IRDocument, track: LectureTrack, pages: readonly ProductionPage[]): Map<string, CaptionTimingSource> {
12
+ const sources = new Map<string, CaptionTimingSource>()
13
+ for (const page of pages) for (const step of page.steps) {
14
+ const section = doc.sections.find(section => section.id === page.sectionId), narrationIds = section?.stepNarrations?.[step.stepId]?.map(item => item.id) ?? []
15
+ const sourceFingerprint = section?.captionSources?.[step.stepId] ?? ''
16
+ const fail = (reason: string) => { sources.set(step.stepId, { available: false, reason, sourceFingerprint, narrationIds }) }
17
+ if (!sourceFingerprint || !narrationIds.length) { fail('caption-script-unavailable'); continue }
18
+ if (step.timing !== 'confirmed' || step.startMs === undefined || step.endMs === undefined || step.endMs <= step.startMs) { fail('caption-duration-unconfirmed'); continue }
19
+ const start = step.startMs, durationMs = Math.round(step.endMs - start), until = step.endMs
20
+ const utterances = track.utterances.filter(u => u.section === section?.id && u.step === step.index)
21
+ if (!utterances.length || utterances.some(u => u.unsaid || u.t < start || u.t + u.d > until)
22
+ || track.utterances.some(u => u.t < start && u.t + u.d > start || u.t < until && u.t + u.d > until)) { fail('caption-cross-step-unsupported'); continue }
23
+ const recordings = (track.audio ?? []).filter(a => a.role === 'recording' && a.stepId === step.stepId)
24
+ if (utterances.some(u => !u.recording && (!u.audioSha256 || !u.clip)) || recordings.some(a => !a.sha256) || !recordings.length && !utterances.some(u => u.audioSha256)) { fail('caption-audio-unavailable'); continue }
25
+ const idMap = new Map(utterances.map((u, index) => [u.id, index]))
26
+ const events = track.events.filter(([at, type]) => at >= start && at < until && type !== 'go').map(([at, type, data]) => {
27
+ const { at: anchor, ...rest } = data
28
+ return [Math.round(at - start), type, rest, Array.isArray(anchor) ? [idMap.get(String(anchor[0])) ?? 'foreign', anchor[1]] : null]
29
+ })
30
+ const playbackFingerprint = fnv1a64(JSON.stringify({ durationMs, events,
31
+ utterances: utterances.map(u => ({ t: Math.round(u.t - start), d: u.d, sha256: u.audioSha256, voice: u.voice, narrationIds: u.narrationIds, recording: u.recording })),
32
+ recordings: recordings.map(a => ({ t: Math.round(a.start * 1000 - start), d: Math.round(a.duration * 1000), assetId: a.assetId, sha256: a.sha256, sourceStartMs: a.sourceStartMs ?? 0 })),
33
+ }))
34
+ sources.set(step.stepId, { available: true, sourceFingerprint, playbackFingerprint, durationMs, narrationIds })
35
+ }
36
+ return sources
37
+ }
38
+
39
+ /** Typed canonical text is projected only after both content and real media/time evidence still match. */
40
+ export function applyCaptionTimings(doc: IRDocument, track: LectureTrack, pages: readonly ProductionPage[]): ProductionDiagnostic[] {
41
+ const sources = captionTimingSources(doc, track, pages), diagnostics: ProductionDiagnostic[] = []
42
+ const overrides: NonNullable<LectureTrack['captionOverrides']> = []
43
+ for (const [stepId, value] of Object.entries(doc.production?.captionTimings ?? {})) {
44
+ const page = pages.find(page => page.steps.some(step => step.stepId === stepId)), step = page?.steps.find(step => step.stepId === stepId)
45
+ const section = doc.sections.find(section => section.id === page?.sectionId), source = sources.get(stepId)
46
+ const text = (section?.stepNarrations?.[stepId] ?? []).map(narration => narration.text).join('\n')
47
+ const stale = !source?.available || value.sourceFingerprint !== source.sourceFingerprint || value.playbackFingerprint !== source.playbackFingerprint
48
+ || value.durationMs !== source.durationMs || JSON.stringify(value.narrationIds) !== JSON.stringify(source.narrationIds) || captionTimingProblem(value, text)
49
+ if (stale || !step || step.startMs === undefined || step.endMs === undefined || !section) {
50
+ diagnostics.push({ code: 'production.caption-timing-stale', severity: 'warning', stepId, ...page ? { sectionId: page.sectionId } : {},
51
+ message: STALE[deckLocale(doc.meta.lang)] })
52
+ continue
53
+ }
54
+ overrides.push({ section: section.id, step: step.index, stepId, startMs: step.startMs, endMs: step.endMs,
55
+ groups: value.segments.map(segment => ({ id: `manual:${stepId}:${segment.id}`, text: text.slice(segment.from, segment.to), t: step.startMs! + segment.startMs, d: segment.endMs - segment.startMs, alignment: 'manual' })) })
56
+ }
57
+ track.captionOverrides = overrides
58
+ return diagnostics
59
+ }
@@ -0,0 +1,59 @@
1
+ import type { LectureTrack, Utterance } from './track.ts'
2
+ import { locate } from './words.ts'
3
+
4
+ export interface CaptionGroup { id: string, text: string, t: number, d: number, alignment: 'words' | 'proportional' | 'none' | 'manual' }
5
+
6
+ const MAX_CHARACTERS = 42
7
+ const sentenceEnd = /[。!?!?;;]\s*[”’"'))\]]*\s*$/u
8
+ const hasText = /[\p{L}\p{N}]/u
9
+
10
+ /** Readable display groups; every internal time boundary belongs to an actual word. */
11
+ export function captionGroups(utterance: Utterance): CaptionGroup[] {
12
+ const full: CaptionGroup = { id: utterance.id, text: utterance.text, t: utterance.t, d: utterance.d,
13
+ alignment: utterance.alignment === 'proportional' ? 'proportional' : 'none' }
14
+ const words = utterance.words
15
+ if (utterance.silent || utterance.recording || utterance.unsaid || utterance.alignment !== 'words' || !words?.length || utterance.d <= 0) return [full]
16
+ const places = locate(words.map(word => word[2]), utterance.text)
17
+ let previous = utterance.t, end = 0
18
+ for (const [index, word] of words.entries()) {
19
+ const place = places[index]
20
+ if (!place || !Number.isFinite(word[0]) || !Number.isFinite(word[1]) || word[1] <= 0
21
+ || word[0] < previous || word[0] + word[1] > utterance.t + utterance.d
22
+ || hasText.test(utterance.text.slice(end, place.start))) return [full]
23
+ previous = word[0] + word[1]; end = place.end
24
+ }
25
+ if (hasText.test(utterance.text.slice(end))) return [full]
26
+ const asciiTokens = [...utterance.text.matchAll(/[\x21-\x7e]+/g)].map(match => ({ start: match.index, end: match.index + match[0].length }))
27
+ let token = 0
28
+ const groups: CaptionGroup[] = []
29
+ let from = 0, first = 0
30
+ const append = (last: number, to: number) => {
31
+ const start = first === 0 ? utterance.t : words[first]![0]
32
+ const until = last + 1 < words.length ? words[last + 1]![0] : utterance.t + utterance.d
33
+ if (until <= start) return false
34
+ groups.push({ id: `${utterance.id}:${from}`, text: utterance.text.slice(from, to), t: start, d: until - start, alignment: 'words' })
35
+ from = to; first = last + 1
36
+ return true
37
+ }
38
+ for (let index = 0; index < words.length; index++) {
39
+ const to = places[index + 1]?.start ?? utterance.text.length
40
+ const text = utterance.text.slice(from, to)
41
+ while (asciiTokens[token] && asciiTokens[token]!.end <= to) token++
42
+ const insideToken = asciiTokens[token] && asciiTokens[token]!.start < to && to < asciiTokens[token]!.end
43
+ const next = places[index + 2]?.start ?? utterance.text.length
44
+ if (!insideToken && (index + 1 === words.length || sentenceEnd.test(text) || [...utterance.text.slice(from, next)].length > MAX_CHARACTERS)) {
45
+ if (!append(index, to)) return [full]
46
+ }
47
+ }
48
+ return groups
49
+ }
50
+
51
+ export function captionGroupAt(groups: readonly CaptionGroup[], timeMs: number): CaptionGroup | undefined {
52
+ return groups.find(group => group.t <= timeMs && timeMs < group.t + group.d)
53
+ }
54
+
55
+ /** Shared projection used by publication and playback; an explicit manual gap stays empty. */
56
+ export function trackCaptionGroups(track: LectureTrack): CaptionGroup[] {
57
+ return [...track.utterances.filter(u => !track.captionOverrides?.some(own => own.section === u.section && own.step === u.step)).flatMap(captionGroups),
58
+ ...track.captionOverrides?.flatMap(own => own.groups) ?? []].sort((a, b) => a.t - b.t)
59
+ }
@@ -0,0 +1,67 @@
1
+ import { documentVoice, irBlocks, isRecord, parseDuration, resolvedVoice, speechVoice, voiceOverrides } from '@lincyaw/kernel'
2
+ import type { IRDocument, IRSection, SpeechVoice, VoiceTarget } from '@lincyaw/kernel'
3
+
4
+ export interface LectureVoice {
5
+ engine?: string
6
+ voice: string
7
+ rate?: number
8
+ style?: string
9
+ }
10
+
11
+ export interface NarrationVoice { voice?: SpeechVoice, issue?: string, target?: VoiceTarget }
12
+
13
+ export function configuredLectureVoice(doc: IRDocument): LectureVoice | undefined {
14
+ const overrides = voiceOverrides(doc)
15
+ return documentVoice(doc) ?? Object.values(overrides.pages)[0] ?? overrides.steps[0]?.voice
16
+ }
17
+
18
+ /** Resolve against the complete IR before renderer filtering compacts its steps. */
19
+ export function prepareNarrationVoices(doc: IRDocument): void {
20
+ // JSON lowering resolves each authored narration against its stable step and
21
+ // Production scope. Do not replace those bindings with legacy source hints.
22
+ if (doc.production) return
23
+ const overrides = voiceOverrides(doc)
24
+ for (const section of doc.sections) {
25
+ const bindings = Object.fromEntries([...irBlocks(section.blocks)].filter(block => block.role === 'narration').map(block => {
26
+ const resolved = resolvedVoice(doc, section, block.at)
27
+ const unsafe = resolved.reason && overrides.steps.some(entry => entry.section === section.id)
28
+ return [block.id, { ...resolved.editable ? { target: resolved.target } : {}, ...unsafe ? { issue: resolved.reason } : resolved.voice ? { voice: resolved.voice } : { issue: 'voice-unset' } }]
29
+ }))
30
+ section.config = { ...section.config, 'presmium.lecture': { ...section.config?.['presmium.lecture'], resolvedNarrationVoices: bindings } }
31
+ }
32
+ }
33
+
34
+ export function narrationVoice(doc: IRDocument, section: IRSection, block?: string, step = 0): NarrationVoice {
35
+ const configured = section.config?.['presmium.lecture']?.resolvedNarrationVoices
36
+ const mapped = block && isRecord(configured) ? configured[block] : undefined
37
+ if (isRecord(mapped)) return { ...speechVoice(mapped.voice) ? { voice: speechVoice(mapped.voice) } : {}, ...typeof mapped.issue === 'string' ? { issue: mapped.issue } : {}, ...isRecord(mapped.target) ? { target: mapped.target as unknown as VoiceTarget } : {} }
38
+ const overrides = voiceOverrides(doc)
39
+ if (section.stepMap && overrides.steps.some(entry => entry.section === section.id)) return { issue: 'voice-step-binding-unavailable' }
40
+ const resolved = resolvedVoice(doc, section, step)
41
+ if (resolved.reason && overrides.steps.some(entry => entry.section === section.id)) return { issue: resolved.reason }
42
+ return { ...resolved.editable ? { target: resolved.target } : {}, ...resolved.voice ? { voice: resolved.voice } : { issue: 'voice-unset' } }
43
+ }
44
+
45
+ export function lectureVoice(config: Record<string, unknown> | undefined): LectureVoice | undefined {
46
+ const voice = isRecord(config?.voice) ? config.voice : undefined
47
+ if (typeof voice?.voice !== 'string' || !voice.voice) return undefined
48
+ return {
49
+ voice: voice.voice,
50
+ ...typeof voice.engine === 'string' && voice.engine ? { engine: voice.engine } : {},
51
+ ...typeof voice.rate === 'number' ? { rate: voice.rate } : {},
52
+ ...typeof voice.style === 'string' && voice.style ? { style: voice.style } : {},
53
+ }
54
+ }
55
+
56
+ export function lectureGap(config: Record<string, unknown> | undefined): number {
57
+ const gap = config?.gap
58
+ if (typeof gap === 'number' && gap >= 0) return gap
59
+ return (typeof gap === 'string' ? parseDuration(gap) : undefined) ?? 500
60
+ }
61
+
62
+ export function lexiconOf(config: Record<string, unknown> | undefined): Record<string, string> | undefined {
63
+ const lexicon = isRecord(config?.lexicon) ? config.lexicon : undefined
64
+ if (!lexicon) return undefined
65
+ const entries = Object.entries(lexicon).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
66
+ return entries.length ? Object.fromEntries(entries) : undefined
67
+ }
@@ -0,0 +1,159 @@
1
+ import { fingerprint, fnv1a64, irBlocks } from '@lincyaw/kernel'
2
+ import type { IRBlock, IRDocument, MdastNode, TrackEvent } from '@lincyaw/kernel'
3
+ import { anchorOf } from './timing.ts'
4
+ import type { LectureTrack, TrackBlock } from './track.ts'
5
+ import { PLAYER_RENDERER } from './track.ts'
6
+
7
+ export type Grade = 'exact' | 'moved' | 'fuzzy' | 'lost'
8
+
9
+ export function blockKey(section: string, block: string): string {
10
+ return `${section}/${block}`
11
+ }
12
+
13
+ export function blockOf(data: Record<string, unknown>): string | undefined {
14
+ if (typeof data.block !== 'string' || !data.block) return undefined
15
+ if (typeof data.section === 'string' && !data.block.includes('/')) return blockKey(data.section, data.block)
16
+ return data.block.includes('/') ? data.block : undefined
17
+ }
18
+
19
+ function text(node: MdastNode | undefined): string {
20
+ if (!node) return ''
21
+ const own = [node.value, node.alt].filter((v): v is string => typeof v === 'string')
22
+ return [...own, ...(node.children ?? []).map(text)].join(' ')
23
+ }
24
+
25
+ function content(block: IRBlock): string {
26
+ const { node } = block
27
+ if (node.type === 'code') return node.value
28
+ if (node.type === 'markdown') return text(node.mdast)
29
+ const files = new Set(Object.keys(node.assets ?? {}).map(path => path.split('.')[0]))
30
+ const props = Object.fromEntries(Object.entries(node.props).filter(([key]) => !files.has(key)))
31
+ return [node.name, JSON.stringify(props), ...(node.children ?? []).map(content)].join('\n')
32
+ }
33
+
34
+ export function codeLineCount(block: IRBlock): number | undefined {
35
+ return block.node.type === 'code' ? block.node.value.replace(/\n$/, '').split('\n').length : undefined
36
+ }
37
+
38
+ export function lastNamedLine(lines: unknown): number | undefined {
39
+ if (typeof lines !== 'string' && typeof lines !== 'number') return undefined
40
+ const numbers = String(lines).match(/\d+/g)?.map(Number) ?? []
41
+ return numbers.length ? Math.max(...numbers) : undefined
42
+ }
43
+
44
+ export function trackBlock(block: IRBlock): TrackBlock {
45
+ const fp = fingerprint(content(block))
46
+ const lines = codeLineCount(block)
47
+ return lines === undefined ? { fp } : { fp, lines }
48
+ }
49
+
50
+ export function playedBlocks(doc: IRDocument): Map<string, TrackBlock> {
51
+ const blocks = new Map<string, TrackBlock>()
52
+ for (const section of doc.sections) {
53
+ for (const block of irBlocks(section.blocks)) {
54
+ if (block.only && !block.only.includes(PLAYER_RENDERER)) continue
55
+ blocks.set(blockKey(section.id, block.id), trackBlock(block))
56
+ }
57
+ }
58
+ return blocks
59
+ }
60
+
61
+ export function irHash(doc: IRDocument): string {
62
+ return fnv1a64([...playedBlocks(doc)].map(([key, block]) => `${key}\t${block.fp}`).join('\n'))
63
+ }
64
+
65
+ export function sectionHashes(doc: IRDocument): Record<string, string> {
66
+ return Object.fromEntries(doc.sections.map(section => [section.id, irHash({ ...doc, sections: [section] })]))
67
+ }
68
+
69
+ export function unchanged(track: LectureTrack, doc: IRDocument): boolean {
70
+ const recorded = track.ir.sections
71
+ if (!recorded) return track.ir.hash === irHash(doc)
72
+ const now = sectionHashes(doc)
73
+ return Object.entries(recorded).every(([section, hash]) => now[section] === hash)
74
+ }
75
+
76
+ export function referencedBlocks(doc: IRDocument, events: readonly TrackEvent[]): Record<string, TrackBlock> {
77
+ const blocks = playedBlocks(doc)
78
+ const referenced: Record<string, TrackBlock> = {}
79
+ for (const [, , data] of events) {
80
+ const key = blockOf(data)
81
+ const block = key && blocks.get(key)
82
+ if (block) referenced[key] = block
83
+ }
84
+ return referenced
85
+ }
86
+
87
+ export interface BlockDrift {
88
+ grade: Grade
89
+ to?: string
90
+ }
91
+
92
+ export function blockDrift(track: LectureTrack, doc: IRDocument): Map<string, BlockDrift> {
93
+ const current = playedBlocks(doc)
94
+ const byFingerprint = new Map<string, string[]>()
95
+ for (const [key, block] of current) byFingerprint.set(block.fp, [...byFingerprint.get(block.fp) ?? [], key])
96
+ const keys = new Set([...Object.keys(track.blocks), ...track.events.flatMap(([, , data]) => blockOf(data) ?? [])])
97
+ const drift = new Map<string, BlockDrift>()
98
+ for (const key of keys) {
99
+ const recorded = track.blocks[key]
100
+ const now = current.get(key)
101
+ if (!recorded) {
102
+ drift.set(key, { grade: now ? 'exact' : 'lost' })
103
+ continue
104
+ }
105
+ if (now?.fp === recorded.fp) {
106
+ drift.set(key, { grade: 'exact' })
107
+ continue
108
+ }
109
+ const elsewhere = byFingerprint.get(recorded.fp) ?? []
110
+ if (elsewhere.length === 1) drift.set(key, { grade: 'moved', to: elsewhere[0] })
111
+ else drift.set(key, { grade: now ? 'fuzzy' : 'lost' })
112
+ }
113
+ return drift
114
+ }
115
+
116
+ export interface EventDrift {
117
+ index: number
118
+ grade: Grade
119
+ reason?: 'block' | 'section' | 'steps' | 'lines' | 'at'
120
+ block?: string
121
+ data: Record<string, unknown>
122
+ }
123
+
124
+ export function eventDrift(track: LectureTrack, doc: IRDocument): EventDrift[] {
125
+ const blocks = blockDrift(track, doc)
126
+ const current = playedBlocks(doc)
127
+ const sections = new Map(doc.sections.map(s => [s.id, s]))
128
+ const utterances = new Set(track.utterances.map(u => u.id))
129
+ return track.events.map(([, type, data], index): EventDrift => {
130
+ if (type === 'go') {
131
+ const section = typeof data.section === 'string' ? sections.get(data.section) : undefined
132
+ if (!section) return { index, grade: 'lost', reason: 'section', data }
133
+ const step = Number(data.step)
134
+ if (Number.isFinite(step) && step > section.steps) return { index, grade: 'fuzzy', reason: 'steps', data }
135
+ return { index, grade: 'exact', data }
136
+ }
137
+ const key = blockOf(data)
138
+ const at = anchorOf(data)
139
+ if (!key) return at && !utterances.has(at[0]) ? { index, grade: 'fuzzy', reason: 'at', data } : { index, grade: 'exact', data }
140
+ const drift = blocks.get(key) ?? { grade: 'lost' }
141
+ if (drift.grade === 'lost') return { index, grade: 'lost', reason: 'block', block: key, data }
142
+ const now = drift.to ?? key
143
+ const moved = drift.to ? moveTo(data, drift.to) : data
144
+ const last = lastNamedLine(data.lines ?? data.line)
145
+ const lines = current.get(now)?.lines
146
+ if (last !== undefined && lines !== undefined && last > lines) return { index, grade: 'lost', reason: 'lines', block: key, data: moved }
147
+ if (drift.grade !== 'exact') return { index, grade: drift.grade, reason: 'block', block: key, data: moved }
148
+ if (at && !utterances.has(at[0])) return { index, grade: 'fuzzy', reason: 'at', data }
149
+ return { index, grade: 'exact', data }
150
+ })
151
+ }
152
+
153
+ function moveTo(data: Record<string, unknown>, key: string): Record<string, unknown> {
154
+ const cut = key.lastIndexOf('/')
155
+ if (typeof data.section === 'string' && typeof data.block === 'string' && !data.block.includes('/')) {
156
+ return { ...data, section: key.slice(0, cut), block: key.slice(cut + 1) }
157
+ }
158
+ return { ...data, block: key }
159
+ }