@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.2
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,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
|
+
}
|