@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,112 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { closeSync, createReadStream, fstatSync } from 'node:fs'
3
+ import { Input, ALL_FORMATS, BufferSource } from 'mediabunny'
4
+ import { productionAssetIds, ProductionAudioRefusal } from '@lincyaw/kernel'
5
+ import type { IRDocument, ProductionAssetInspection } from '@lincyaw/kernel'
6
+ import { productionAssetFile } from '@lincyaw/kernel/node'
7
+ import { openInDeck } from './store.ts'
8
+
9
+ export class ProductionAssetRefusal extends ProductionAudioRefusal {
10
+ constructor(code: string) { super(code === 'production.asset-changed' ? 409 : 422, code) }
11
+ }
12
+ export interface ProductionAssetFile { asset: ProductionAssetInspection, path: string, stamp: string, extension: string }
13
+ const MAX_BYTES = 16 << 20
14
+ const MAX_DURATION_MS = 10 * 60 * 1000
15
+ const extensions: Record<string, string> = { 'audio/ogg': 'ogg', 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/webm': 'webm' }
16
+ const stamp = (file: ReturnType<typeof fstatSync>) => [file.dev, file.ino, file.size, file.mtimeMs, file.ctimeMs].join(':')
17
+
18
+ async function readAudioFile(deckDir: string, file: string, signal?: AbortSignal): Promise<{ bytes: Buffer, identity: string }> {
19
+ signal?.throwIfAborted()
20
+ const fd = openInDeck(deckDir, file)
21
+ if (fd === undefined) throw new ProductionAssetRefusal('production.asset-file')
22
+ let bytes: Buffer, identity: string
23
+ try {
24
+ const before = fstatSync(fd)
25
+ if (!before.size || before.size > MAX_BYTES) throw new ProductionAssetRefusal('production.asset-size')
26
+ identity = stamp(before)
27
+ const chunks: Buffer[] = []
28
+ let length = 0
29
+ for await (const value of createReadStream('', { fd, autoClose: false, signal })) {
30
+ const chunk = Buffer.from(value)
31
+ length += chunk.length
32
+ if (length > MAX_BYTES) throw new ProductionAssetRefusal('production.asset-size')
33
+ chunks.push(chunk)
34
+ }
35
+ if (stamp(fstatSync(fd)) !== identity) throw new ProductionAssetRefusal('production.asset-changed')
36
+ bytes = Buffer.concat(chunks)
37
+ } catch (error) {
38
+ signal?.throwIfAborted()
39
+ if (error instanceof ProductionAssetRefusal) throw error
40
+ throw new ProductionAssetRefusal('production.asset-file')
41
+ } finally { closeSync(fd) }
42
+ signal?.throwIfAborted()
43
+ return { bytes, identity }
44
+ }
45
+
46
+ /** Uploads and already-declared files use the same actual-byte probe. No file is written here. */
47
+ export async function inspectProductionAudioBytes(assetId: string, value: Uint8Array, signal?: AbortSignal): Promise<{ inspection: ProductionAssetInspection, extension: string }> {
48
+ signal?.throwIfAborted()
49
+ if (!value.byteLength || value.byteLength > MAX_BYTES) throw new ProductionAssetRefusal('production.asset-size')
50
+ const bytes = Buffer.from(value), sha256 = createHash('sha256').update(bytes).digest('hex')
51
+ const input = new Input({ formats: ALL_FORMATS, source: new BufferSource(bytes) })
52
+ const cancel = () => input.dispose()
53
+ signal?.addEventListener('abort', cancel, { once: true })
54
+ try {
55
+ signal?.throwIfAborted()
56
+ const audio = await input.getAudioTracks(), video = await input.getVideoTracks()
57
+ if (audio.length !== 1 || video.length || audio[0]!.numberOfChannels < 1 || audio[0]!.numberOfChannels > 2) throw new ProductionAssetRefusal('production.asset-format')
58
+ const mediaType = (await input.getMimeType()).split(';')[0]!.trim(), extension = extensions[mediaType]
59
+ if (!extension || !audio[0]!.codec) throw new ProductionAssetRefusal('production.asset-format')
60
+ const durationMs = Math.round(await input.computeDuration() * 1000)
61
+ if (!Number.isFinite(durationMs) || durationMs <= 0 || durationMs > MAX_DURATION_MS) throw new ProductionAssetRefusal('production.asset-duration')
62
+ signal?.throwIfAborted()
63
+ return { extension, inspection: { assetId, mediaId: createHash('sha256').update(JSON.stringify(['presmium.asset-audio', assetId, sha256])).digest('hex').slice(0, 32), kind: 'audio', sha256, durationMs, byteLength: bytes.length, mediaType, codec: audio[0]!.codec, sampleRate: audio[0]!.sampleRate, channels: audio[0]!.numberOfChannels } }
64
+ } catch (error) {
65
+ signal?.throwIfAborted()
66
+ if (error instanceof ProductionAssetRefusal) throw error
67
+ throw new ProductionAssetRefusal('production.asset-format')
68
+ } finally { signal?.removeEventListener('abort', cancel); input.dispose() }
69
+ }
70
+
71
+ /** Reads only a declared asset ID, through the same-document regular-file boundary. */
72
+ export async function inspectProductionAsset(doc: Pick<IRDocument, 'productionAssets'>, deckDir: string, assetId: string, signal?: AbortSignal): Promise<ProductionAssetFile> {
73
+ signal?.throwIfAborted()
74
+ const declared = doc.productionAssets?.[assetId]
75
+ if (!declared) throw new ProductionAssetRefusal('production.asset-missing')
76
+ if (declared.kind !== 'audio') throw new ProductionAssetRefusal('production.asset-kind')
77
+ const file = productionAssetFile(deckDir, declared.location)
78
+ if (!file) throw new ProductionAssetRefusal('production.asset-path')
79
+ const { bytes, identity } = await readAudioFile(deckDir, file, signal)
80
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
81
+ if (declared.fingerprint && declared.fingerprint.replace(/^sha256:/, '').toLowerCase() !== sha256) throw new ProductionAssetRefusal('production.asset-fingerprint')
82
+ const probed = await inspectProductionAudioBytes(assetId, bytes, signal)
83
+ return { path: file, stamp: identity, extension: probed.extension, asset: probed.inspection }
84
+ }
85
+
86
+ /** Snapshot bytes are checked before a response or published copy can adopt them. */
87
+ export async function readProductionAssetFile(file: ProductionAssetFile, deckDir: string, signal?: AbortSignal): Promise<Buffer> {
88
+ const { bytes, identity } = await readAudioFile(deckDir, file.path, signal)
89
+ if (identity !== file.stamp || createHash('sha256').update(bytes).digest('hex') !== file.asset.sha256) throw new ProductionAssetRefusal('production.asset-changed')
90
+ return bytes
91
+ }
92
+
93
+ export function assertProductionAssetFile(file: ProductionAssetFile, deckDir: string): void {
94
+ const fd = openInDeck(deckDir, file.path)
95
+ if (fd === undefined) throw new ProductionAssetRefusal('production.asset-changed')
96
+ try { if (stamp(fstatSync(fd)) !== file.stamp) throw new ProductionAssetRefusal('production.asset-changed') }
97
+ finally { closeSync(fd) }
98
+ }
99
+
100
+ export async function inspectProductionAssets(doc: IRDocument, deckDir: string, signal?: AbortSignal): Promise<{ assets: Map<string, ProductionAssetFile>, errors: Map<string, string> }> {
101
+ const assets = new Map<string, ProductionAssetFile>(), errors = new Map<string, string>()
102
+ const ids = productionAssetIds(doc.production, doc.sections).sort()
103
+ if (ids.length > 32) { ids.forEach(id => errors.set(id, 'production.asset-count')); return { assets, errors } }
104
+ for (let offset = 0; offset < ids.length; offset += 2) await Promise.all(ids.slice(offset, offset + 2).map(async id => {
105
+ try { assets.set(id, await inspectProductionAsset(doc, deckDir, id, signal)) }
106
+ catch (error) { signal?.throwIfAborted(); if (!(error instanceof ProductionAssetRefusal)) throw error; errors.set(id, error.code) }
107
+ }))
108
+ if ([...assets.values()].reduce((bytes, file) => bytes + file.asset.durationMs / 1000 * 48000 * file.asset.channels * 4, 0) > 256 * 1024 * 1024) {
109
+ ids.forEach(id => errors.set(id, 'production.asset-decoded-size')); assets.clear()
110
+ }
111
+ return { assets, errors }
112
+ }
@@ -0,0 +1,102 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { stat } from 'node:fs/promises'
3
+ import type { CanonicalSource, IRDocument, ProductionAssetAnswer, ProductionAssetQuery, ProductionAudio, ProductionMediaQuery, ProductionPlan, ProductionPlanQuery } from '@lincyaw/kernel'
4
+ import { irRevision } from '@lincyaw/kernel/node'
5
+ import type { LectureTrack } from '../domain/index.ts'
6
+ import { projectProductionPlan } from '../domain/production.ts'
7
+ import { latestDoc, lectureView } from './latest-ir.ts'
8
+ import { assertVoiceSource } from './voice-source.ts'
9
+ import { synthesizeLecture } from './synthesize.ts'
10
+ import type { SynthesizedClip } from './synthesize.ts'
11
+ import type { ServedDeck } from './routes/served-deck.ts'
12
+ import { RouteRefusal } from './routes/served-deck.ts'
13
+ import { assertProductionAssetFile, inspectProductionAsset, readProductionAssetFile } from './production-assets.ts'
14
+ import type { ProductionAssetFile } from './production-assets.ts'
15
+
16
+ interface ClipFile { audio?: ProductionAudio, asset?: ProductionAssetFile, path: string, stamp: string }
17
+ export interface ProductionSnapshot { plan: ProductionPlan, track: LectureTrack, clips: Map<string, ClipFile> }
18
+ const recent = new WeakMap<ServedDeck, Map<string, ProductionSnapshot>>()
19
+ const fileStamp = async (path: string) => { const value = await stat(path); return [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(':') }
20
+
21
+ async function clipFile(clip: SynthesizedClip, signal?: AbortSignal): Promise<ClipFile> {
22
+ signal?.throwIfAborted()
23
+ const before = await fileStamp(clip.path)
24
+ if (before !== clip.stamp || !clip.sha256) throw new RouteRefusal(409, 'production-media-changed')
25
+ return { audio: { clipId: clip.key, key: clip.key, sha256: clip.sha256, durationMs: Math.round(clip.duration * 1000) }, path: clip.path, stamp: before }
26
+ }
27
+
28
+ async function current(served: ServedDeck, expected?: ProductionPlanQuery): Promise<{ doc: IRDocument, source: CanonicalSource, revision: string }> {
29
+ const doc = latestDoc(served.deckDir)
30
+ if (!doc) throw new RouteRefusal(503, 'production-document-unavailable')
31
+ const source = await assertVoiceSource(doc, served.deckDir, expected?.source), revision = irRevision(doc)
32
+ if (latestDoc(served.deckDir) !== doc || expected && expected.irRevision !== revision) throw new RouteRefusal(409, 'production-revision-changed')
33
+ return { doc, source, revision }
34
+ }
35
+
36
+ /** Cache-only derivation shared by the public plan and the existing lecture player. No provider generation occurs. */
37
+ export async function productionSnapshot(served: ServedDeck, query?: ProductionPlanQuery & { planId?: string }, signal?: AbortSignal): Promise<ProductionSnapshot> {
38
+ signal?.throwIfAborted()
39
+ const now = await current(served, query)
40
+ if (query?.planId) {
41
+ const found = recent.get(served)?.get(query.planId)
42
+ // A source-bound Node service and a route can borrow different ServedDecks.
43
+ // Re-derive only the current cache-only snapshot; never invent a past plan.
44
+ if (!found) {
45
+ const fresh = await productionSnapshot(served, { source: now.source, irRevision: now.revision }, signal)
46
+ if (fresh.plan.binding.planId !== query.planId) throw new RouteRefusal(409, 'production-plan-changed')
47
+ return fresh
48
+ }
49
+ if (found.plan.binding.irRevision !== now.revision) throw new RouteRefusal(409, 'production-plan-changed')
50
+ await Promise.all([...found.clips.values()].map(async file => { if (file.asset) assertProductionAssetFile(file.asset, served.deckDir); else if (await fileStamp(file.path) !== file.stamp) throw new RouteRefusal(409, 'production-media-changed') }))
51
+ await current(served, found.plan.binding)
52
+ signal?.throwIfAborted()
53
+ return found
54
+ }
55
+ let speech
56
+ try { speech = served.speech() } catch { /* Explicit silence requires no speech service. */ }
57
+ const made = await synthesizeLecture(lectureView(now.doc), served.deckDir, speech, signal, { cacheOnly: true }), track = made.preview
58
+ if (!track) throw new RouteRefusal(409, 'production-plan-unavailable')
59
+ const clips = new Map<string, ClipFile>(), unique = [...new Map([...made.clips.values()].map(clip => [clip.key, clip])).values()].sort((a, b) => a.key.localeCompare(b.key))
60
+ for (let offset = 0; offset < unique.length; offset += 4) for (const file of await Promise.all(unique.slice(offset, offset + 4).map(clip => clipFile(clip, signal)))) clips.set(file.audio!.clipId, file)
61
+ for (const file of [...made.assets?.values() ?? []].sort((a, b) => a.asset.mediaId.localeCompare(b.asset.mediaId))) {
62
+ assertProductionAssetFile(file, served.deckDir)
63
+ clips.set(file.asset.mediaId, { asset: file, path: file.path, stamp: file.stamp })
64
+ }
65
+ for (const utterance of track.utterances) {
66
+ const clip = made.clips.get(utterance.id)
67
+ if (clip) utterance.clipId = clip.key
68
+ }
69
+ const projected = projectProductionPlan(now.doc, made.deck.plan, track, new Map([...clips].flatMap(([key, file]) => file.audio ? [[key, file.audio] as const] : [])), made.failures, [...made.mediaDiagnostics ?? [], ...made.captionDiagnostics ?? []], new Map([...made.assets ?? []].map(([id, file]) => [id, file.asset])))
70
+ const planId = createHash('sha256').update(JSON.stringify({ source: now.source, irRevision: now.revision, projected, track, audio: [...clips.values()].map(file => file.audio ?? file.asset!.asset) })).digest('hex').slice(0, 32)
71
+ const binding = { source: now.source, irRevision: now.revision, planId }, plan: ProductionPlan = { binding, ...projected }
72
+ track.binding = binding
73
+ await current(served, binding)
74
+ signal?.throwIfAborted()
75
+ const snapshot = { plan, track, clips }, plans = recent.get(served) ?? new Map<string, ProductionSnapshot>()
76
+ recent.set(served, plans); plans.set(planId, snapshot)
77
+ while (plans.size > 4) plans.delete(plans.keys().next().value!)
78
+ return snapshot
79
+ }
80
+
81
+ export async function productionAsset(served: ServedDeck, query: ProductionAssetQuery, signal?: AbortSignal): Promise<ProductionAssetAnswer> {
82
+ const now = await current(served, query), inspected = await inspectProductionAsset(now.doc, served.deckDir, query.assetId, signal)
83
+ await current(served, { source: now.source, irRevision: now.revision })
84
+ signal?.throwIfAborted()
85
+ return { source: now.source, irRevision: now.revision, asset: inspected.asset }
86
+ }
87
+
88
+ export async function productionMedia(served: ServedDeck, query: ProductionMediaQuery, signal?: AbortSignal): Promise<string | { bytes: Uint8Array<ArrayBuffer>, mediaType: string }> {
89
+ const snapshot = await productionSnapshot(served, query, signal), file = snapshot.clips.get(query.clipId)
90
+ if (!file) throw new RouteRefusal(404, 'production-clip-unknown')
91
+ if (file.asset) {
92
+ const bytes = await readProductionAssetFile(file.asset, served.deckDir, signal)
93
+ await current(served, query); signal?.throwIfAborted()
94
+ return { bytes: new Uint8Array(bytes), mediaType: file.asset.asset.mediaType }
95
+ }
96
+ const hit = await served.speech().cached?.(query.clipId, signal)
97
+ if (!hit || typeof hit.audio !== 'string' || hit.audio !== file.path || Math.round(hit.duration * 1000) !== file.audio!.durationMs) throw new RouteRefusal(409, 'production-media-changed')
98
+ if (await fileStamp(file.path) !== file.stamp) throw new RouteRefusal(409, 'production-media-changed')
99
+ await current(served, query)
100
+ signal?.throwIfAborted()
101
+ return file.path
102
+ }
@@ -0,0 +1,182 @@
1
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { workspaceSettings } from '@lincyaw/compiler'
4
+ import { deckLocale, errorMessage, SPEECH } from '@lincyaw/kernel'
5
+ import type { IRDocument, RenderContext, Renderer, SpeechService } from '@lincyaw/kernel'
6
+ import { captionLanguages, captionsVtt, chaptersVtt, CLIPS_DIR, dropChangedSections, eventsVtt, formatTrack, LECTURE_ID, LECTURE_RENDERER, LECTURE_MESSAGES, plannedUtterances, SCRIPT_TRACK, trackDuration, trimToDeck, utteranceLine } from '../domain/index.ts'
7
+ import type { LectureTrack } from '../domain/index.ts'
8
+ import { cachedClip } from './clips.ts'
9
+ import { latestDoc } from './latest-ir.ts'
10
+ import { deckPlan } from './plan.ts'
11
+ import { mediaFile, mediaPolicy, publishedMedia, readTrack, TRACK_FILE, trackIds } from './store.ts'
12
+ import type { MediaPolicy } from './store.ts'
13
+ import { productionMessages } from '../domain/production.ts'
14
+ import { synthesizeLecture } from './synthesize.ts'
15
+ import { readProductionAssetFile } from './production-assets.ts'
16
+ import type { ProductionAssetFile } from './production-assets.ts'
17
+
18
+ const clipsOf = (track: LectureTrack) => new Set(track.utterances.flatMap(u => u.clip ?? []))
19
+
20
+ async function publishTrack(track: LectureTrack, text: string, file: string, doc: IRDocument, ctx: RenderContext, clipFiles: ReadonlyMap<string, string>, policy: MediaPolicy = { kind: 'ignore' }, assets: ReadonlyMap<string, ProductionAssetFile> = new Map()): Promise<void> {
21
+ const out = join(ctx.outDir, track.id)
22
+ mkdirSync(out, { recursive: true })
23
+ const audio = publishedMedia(track.audio, track.id, policy)
24
+ const video = publishedMedia(track.video, track.id, policy)
25
+ writeFileSync(join(out, TRACK_FILE), formatTrack({ ...track, ...audio ? { audio } : {}, ...video ? { video } : {} }))
26
+ for (const segment of policy.kind === 'url' ? [] : [...track.audio ?? [], ...track.video ?? []]) {
27
+ if (segment.assetId) {
28
+ const asset = assets.get(segment.assetId)
29
+ if (!asset || segment.sha256 !== asset.asset.sha256) throw new Error('production.asset-changed')
30
+ const bytes = await readProductionAssetFile(asset, ctx.deckDir)
31
+ mkdirSync(dirname(join(out, segment.src)), { recursive: true })
32
+ writeFileSync(join(out, segment.src), bytes)
33
+ continue
34
+ }
35
+ const source = mediaFile(ctx.deckDir, track.id, segment.src)
36
+ if (!source) continue
37
+ if (!existsSync(source)) {
38
+ ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: `lecture ${track.id}: ${segment.src} is not in this checkout; the lecture plays without it`, source: { file } })
39
+ continue
40
+ }
41
+ mkdirSync(dirname(join(out, segment.src)), { recursive: true })
42
+ copyFileSync(source, join(out, segment.src))
43
+ }
44
+ for (const clip of clipsOf(track)) {
45
+ const source = clipFiles.get(clip)
46
+ if (!source || !existsSync(source)) {
47
+ ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: `lecture ${track.id}: the clip ${clip} is not in the synthesis cache; its utterance plays silent`, source: { file } })
48
+ continue
49
+ }
50
+ mkdirSync(join(out, CLIPS_DIR), { recursive: true })
51
+ copyFileSync(source, join(out, CLIPS_DIR, clip))
52
+ }
53
+ for (const lang of captionLanguages(track)) {
54
+ const captions = captionsVtt(track, lang)
55
+ writeFileSync(join(out, `captions.${lang}.vtt`), captions.vtt)
56
+ for (const id of captions.stale) {
57
+ ctx.report({
58
+ checkId: LECTURE_ID,
59
+ severity: 'warning',
60
+ message: `lecture ${track.id}: the ${lang} translation of ${id} is stale: its original changed after it was translated`,
61
+ source: { file, line: utteranceLine(text, id) },
62
+ hint: 'translate the utterance again',
63
+ })
64
+ }
65
+ if (captions.missing.length > 0) {
66
+ ctx.report({
67
+ checkId: LECTURE_ID,
68
+ severity: 'warning',
69
+ message: `lecture ${track.id}: the ${lang} captions leave out ${captions.missing.join(', ')}, which have no ${lang} translation`,
70
+ source: { file, line: utteranceLine(text, captions.missing[0]!) },
71
+ hint: 'translate those utterances too',
72
+ })
73
+ }
74
+ }
75
+ const titles = new Map(doc.sections.map(s => [s.id, s.title]))
76
+ writeFileSync(join(out, 'chapters.vtt'), chaptersVtt(track, section => titles.get(section)))
77
+ writeFileSync(join(out, 'events.vtt'), eventsVtt(track))
78
+ }
79
+
80
+ async function publishSynthesized(doc: IRDocument, ctx: RenderContext, index: unknown[]): Promise<boolean> {
81
+ const source = { file: doc.meta.entry, line: 1 }, t = LECTURE_MESSAGES[deckLocale(doc.meta.lang)].production
82
+ const speech = ctx.service?.(SPEECH)
83
+ if (!speech && plannedUtterances(deckPlan(doc).plan).some(unit => unit.sourceKind !== 'none' && unit.sourceKind !== 'recording')) {
84
+ ctx.report({ checkId: LECTURE_ID, severity: 'error', message: t.missingSpeech, source })
85
+ return false
86
+ }
87
+ const synthesized = await synthesizeLecture(doc, ctx.deckDir, speech, undefined, { cacheOnly: true })
88
+ for (const issue of synthesized.mediaDiagnostics ?? []) ctx.report({ checkId: LECTURE_ID, severity: issue.severity, message: issue.message, source })
89
+ if (synthesized.mediaDiagnostics?.length) throw new Error(synthesized.mediaDiagnostics[0]!.message)
90
+ for (const issue of synthesized.captionDiagnostics ?? []) ctx.report({ checkId: LECTURE_ID, severity: issue.severity, message: issue.message, source })
91
+ for (const failure of synthesized.failures) {
92
+ ctx.report({ checkId: LECTURE_ID, severity: 'error', message: t.cacheMissing(failure.text.slice(0, 40), failure.section, failure.reason), source: failure.source ?? source, hint: t.generateHint })
93
+ }
94
+ for (const cut of synthesized.cuts) {
95
+ ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: `lecture ${SCRIPT_TRACK}: {cue}\`next\` moves on ${Math.round(cut.left)} ms before the animation of step ${cut.step} of section ${cut.section} has played`, source: cut.source ?? source, hint: 'say the cue later, or shorten the animation (:durations:)' })
96
+ }
97
+ const { track } = synthesized
98
+ if (!track) return false
99
+ const clipFiles = new Map([...synthesized.clips.values()].map(clip => [clip.file, clip.path]))
100
+ await publishTrack(track, formatTrack(track), doc.meta.entry, doc, ctx, clipFiles, { kind: 'ignore' }, synthesized.assets)
101
+ index.push(summary(track))
102
+ return true
103
+ }
104
+
105
+ function publishedTrack(doc: IRDocument, deckDir: string): (track: LectureTrack) => LectureTrack | undefined {
106
+ const latest = latestDoc(deckDir)
107
+ const own = latest?.meta.entry === doc.meta.entry ? latest : doc
108
+ if (typeof own.meta.audience === 'string') return (track) => {
109
+ const kept = trimToDeck(track, own)
110
+ return kept && dropChangedSections(kept, own)
111
+ }
112
+ return track => trimToDeck(track, own)
113
+ }
114
+
115
+ async function recordedClipFiles(track: LectureTrack, speech: SpeechService | undefined): Promise<{ clipFiles: Map<string, string>, refused?: string }> {
116
+ const clipFiles = new Map<string, string>()
117
+ for (const clip of clipsOf(track)) {
118
+ if (!speech) return { clipFiles, refused: 'its clips are given through the speech service, which checks a cloned voice\'s consent, and no plugin provides it; add @lincyaw/addon-speech to plugins' }
119
+ try {
120
+ const found = await cachedClip(speech, clip)
121
+ if (found) clipFiles.set(clip, found.audio)
122
+ } catch (error) {
123
+ return { clipFiles, refused: `the clip ${clip} may not be published: ${errorMessage(error)}` }
124
+ }
125
+ }
126
+ return { clipFiles }
127
+ }
128
+
129
+ function summary(track: LectureTrack) {
130
+ return { id: track.id, lang: track.lang, voice: track.voice, duration: trackDuration(track), captions: captionLanguages(track) }
131
+ }
132
+
133
+ export const lectureRenderer: Renderer = {
134
+ id: LECTURE_RENDERER,
135
+ site: true,
136
+ support: () => 'full',
137
+ async build(doc, ctx) {
138
+ const index: unknown[] = []
139
+ const plan = deckPlan(doc)
140
+ plan.findings.forEach(ctx.report)
141
+ if (plan.findings.some(finding => finding.severity === 'error')) throw new Error(plan.findings.find(finding => finding.severity === 'error')!.message)
142
+ if (!plan.voice && !plan.silent && !plan.recorded && plannedUtterances(plan.plan).length) throw new Error(productionMessages(doc).unset)
143
+ const synthesize = Boolean(plan.voice || plan.silent || plan.recorded)
144
+ const policy = mediaPolicy(workspaceSettings(ctx.workspaceRoot).settings[LECTURE_ID] ?? {})
145
+ const published = publishedTrack(doc, ctx.deckDir)
146
+ const complete = !synthesize || await publishSynthesized(doc, ctx, index)
147
+ if (!complete) throw new Error(LECTURE_MESSAGES[deckLocale(doc.meta.lang)].production.incompleteCache)
148
+ for (const id of trackIds(ctx.deckDir)) {
149
+ if (synthesize && id === SCRIPT_TRACK) {
150
+ ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: `lectures/${SCRIPT_TRACK} is not published: ${SCRIPT_TRACK} is the id of the synthesized lecture` })
151
+ continue
152
+ }
153
+ let stored
154
+ try {
155
+ stored = readTrack(ctx.deckDir, id)
156
+ } catch (error) {
157
+ ctx.report({ checkId: LECTURE_ID, severity: 'error', message: `lecture ${id}: ${errorMessage(error)}` })
158
+ continue
159
+ }
160
+ if (!stored) continue
161
+ const track = published(stored.track)
162
+ if (!track) continue
163
+ const { clipFiles, refused } = await recordedClipFiles(track, ctx.service?.(SPEECH))
164
+ if (refused) {
165
+ ctx.report({ checkId: LECTURE_ID, severity: 'error', message: `lecture ${id} is not published: ${refused}`, source: { file: stored.file } })
166
+ continue
167
+ }
168
+ await publishTrack(track, stored.text, stored.file, doc, ctx, clipFiles, policy)
169
+ index.push(summary(track))
170
+ }
171
+ mkdirSync(ctx.outDir, { recursive: true })
172
+ writeFileSync(join(ctx.outDir, 'index.json'), `${JSON.stringify(index, null, 2)}\n`)
173
+ return { outDir: ctx.outDir, skipped: [] }
174
+ },
175
+ async dev(_doc, ctx) {
176
+ return {
177
+ url: `${ctx.server?.url ?? ''}/__presmium/${LECTURE_ID}/tracks`,
178
+ update: async () => {},
179
+ close: async () => {},
180
+ }
181
+ },
182
+ }
@@ -0,0 +1,23 @@
1
+ import type { RoleSpec } from '@lincyaw/kernel'
2
+ import { cueProblem, LECTURE_ID, parseCue } from '../domain/index.ts'
3
+
4
+ export const cueRole: RoleSpec = {
5
+ name: 'cue',
6
+ options: { to: 'string', lines: 'string', sel: 'string' },
7
+ children(input, ctx) {
8
+ const parsed = parseCue(input.value)
9
+ if ('problem' in parsed) ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: cueProblem(input.value, parsed.problem, ctx.lang) })
10
+ return []
11
+ },
12
+ }
13
+
14
+ export const sayRole: RoleSpec = {
15
+ name: 'say',
16
+ options: { alias: 'string' },
17
+ children(input, ctx) {
18
+ if (typeof input.options.alias !== 'string' || !input.options.alias.trim()) {
19
+ ctx.report({ checkId: LECTURE_ID, severity: 'warning', message: `{say}\`${input.value}\` has no alias="…" to say; the lecture says what it shows` })
20
+ }
21
+ return [{ type: 'text', value: input.value }]
22
+ },
23
+ }
@@ -0,0 +1,51 @@
1
+ import { errorMessage, jsonResponse } from '@lincyaw/kernel'
2
+ import type { ProductionMediaQuery, ProductionPlanQuery, Route } from '@lincyaw/kernel'
3
+ import { productionAsset, productionMedia, productionSnapshot } from '../production.ts'
4
+ import { ProductionAssetRefusal } from '../production-assets.ts'
5
+ import { canonicalSource, VoiceSourceRefusal } from '../voice-source.ts'
6
+ import { serveFile } from './serve-file.ts'
7
+ import { RouteRefusal } from './served-deck.ts'
8
+ import type { ServedDeck } from './served-deck.ts'
9
+
10
+ function audioBytes(bytes: Uint8Array<ArrayBuffer>, mediaType: string, request: Request): Response {
11
+ const size = bytes.byteLength, headers = { 'Content-Type': mediaType, 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' }
12
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
13
+ if (!range || !range[1] && !range[2]) return new Response(request.method === 'HEAD' ? null : bytes, { headers: { ...headers, 'Content-Length': String(size) } })
14
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
15
+ const end = range[1] && range[2] ? Math.min(size - 1, Number(range[2])) : size - 1
16
+ if (start >= size || start > end) return new Response(null, { status: 416, headers: { ...headers, 'Content-Range': `bytes */${size}` } })
17
+ return new Response(request.method === 'HEAD' ? null : bytes.subarray(start, end + 1), { status: 206, headers: { ...headers, 'Content-Length': String(end - start + 1), 'Content-Range': `bytes ${start}-${end}/${size}` } })
18
+ }
19
+
20
+ function query(request: Request, media = false): ProductionPlanQuery | ProductionMediaQuery {
21
+ const params = new URL(request.url).searchParams
22
+ let source: unknown
23
+ try { source = JSON.parse(params.get('source') ?? 'null') } catch { throw new RouteRefusal(400, 'production-query-invalid') }
24
+ const irRevision = params.get('irRevision')
25
+ if (!canonicalSource(source) || !irRevision || !/^[\da-f]{16}$/.test(irRevision)) throw new RouteRefusal(400, 'production-query-invalid')
26
+ if (!media) return { source, irRevision }
27
+ const planId = params.get('planId'), clipId = params.get('clipId')
28
+ if (!planId || !/^[\da-f]{32}$/.test(planId) || !clipId || !/^[\da-f]{32}$/.test(clipId)) throw new RouteRefusal(400, 'production-query-invalid')
29
+ return { source, irRevision, planId, clipId }
30
+ }
31
+
32
+ export function productionRoutes(served: ServedDeck): Record<string, Route> {
33
+ const read = (handle: (request: Request) => Promise<Response>): Route => ({ role: 'reader', async handle(request) {
34
+ try {
35
+ if (request.method !== 'GET' && request.method !== 'HEAD') throw new RouteRefusal(405, 'GET or HEAD')
36
+ return await handle(request)
37
+ } catch (error) { return jsonResponse({ error: errorMessage(error) }, error instanceof RouteRefusal ? error.status : error instanceof VoiceSourceRefusal ? 409 : error instanceof ProductionAssetRefusal ? error.code === 'production.asset-changed' ? 409 : 422 : 502) }
38
+ } })
39
+ return {
40
+ 'production-plan': read(async request => jsonResponse((await productionSnapshot(served, query(request), request.signal)).plan)),
41
+ 'production-asset': read(async request => {
42
+ const assetId = new URL(request.url).searchParams.get('assetId')
43
+ if (!assetId || assetId.length > 200) throw new RouteRefusal(400, 'production-query-invalid')
44
+ return jsonResponse(await productionAsset(served, { ...query(request), assetId }, request.signal))
45
+ }),
46
+ 'production-media': read(async request => {
47
+ const media = await productionMedia(served, query(request, true) as ProductionMediaQuery, request.signal)
48
+ return typeof media === 'string' ? serveFile(served.deckDir, media, request) : audioBytes(media.bytes, media.mediaType, request)
49
+ }),
50
+ }
51
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { errorMessage, isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
3
+ import type { Route } from '@lincyaw/kernel'
4
+ import { fileStem, recordedTrack, spliceSection, trackDuration } from '../../domain/index.ts'
5
+ import type { LectureTrack, MediaSegment, Recording, TrackHead } from '../../domain/index.ts'
6
+ import { freeMediaPath, mediaFile, newTrackId, readTrack, sha256File, trackDir, writeGitFiles, writeTrack } from '../store.ts'
7
+ import { decodedPath } from './served-deck.ts'
8
+ import type { ServedDeck } from './served-deck.ts'
9
+
10
+ const MAX_CHUNK = 64 * 1024 * 1024
11
+ const MAX_FILE = 4 * 1024 * 1024 * 1024
12
+
13
+ function withHashes<T extends MediaSegment>(deckDir: string, id: string, segments: T[]): Promise<T[]> {
14
+ return Promise.all(segments.map(async (segment) => {
15
+ const file = mediaFile(deckDir, id, segment.src)
16
+ const sha256 = file && await sha256File(deckDir, file)
17
+ return { ...segment, ...sha256 ? { sha256 } : {} }
18
+ }))
19
+ }
20
+
21
+ function toRecording(value: unknown): Recording | undefined {
22
+ if (!isRecord(value) || !Array.isArray(value.events)) return undefined
23
+ const list = (v: unknown) => (Array.isArray(v) ? v : [])
24
+ return {
25
+ events: value.events as Recording['events'],
26
+ audio: list(value.audio) as Recording['audio'],
27
+ video: list(value.video) as Recording['video'],
28
+ utterances: list(value.utterances) as Recording['utterances'],
29
+ duration: typeof value.duration === 'number' ? value.duration : 0,
30
+ }
31
+ }
32
+
33
+ function toTrackHead(value: unknown, id: string): TrackHead | undefined {
34
+ if (!isRecord(value) || typeof value.doc !== 'string' || typeof value.irHash !== 'string') return undefined
35
+ return {
36
+ id,
37
+ doc: value.doc,
38
+ lang: typeof value.lang === 'string' ? value.lang : 'zh-CN',
39
+ irHash: value.irHash,
40
+ sections: isRecord(value.sections) ? Object.fromEntries(Object.entries(value.sections).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) : {},
41
+ voice: isRecord(value.voice) && value.voice.kind === 'recorded'
42
+ ? { kind: 'recorded', ...typeof value.voice.speaker === 'string' ? { speaker: value.voice.speaker } : {} }
43
+ : { kind: 'recorded' },
44
+ blocks: isRecord(value.blocks) ? value.blocks as TrackHead['blocks'] : {},
45
+ }
46
+ }
47
+
48
+ export function recordingRoutes(served: ServedDeck): Record<string, Route> {
49
+ const { ctx, deckDir, files, policy } = served
50
+
51
+ const finish = async (id: string, request: Request): Promise<Response> => {
52
+ const body: unknown = await request.json().catch(() => undefined)
53
+ const recording = isRecord(body) ? toRecording(body.recording) : undefined
54
+ const head = isRecord(body) ? toTrackHead(body.head, id) : undefined
55
+ if (!recording || !head) return problemResponse(400, 'expected { head, recording }')
56
+ const media = { ...recording, audio: await withHashes(deckDir, id, recording.audio), video: await withHashes(deckDir, id, recording.video) }
57
+ const section = isRecord(body) && typeof body.section === 'string' ? body.section : undefined
58
+ const stored = readTrack(deckDir, id)
59
+ let track: LectureTrack
60
+ if (section && stored) {
61
+ const spliced = spliceSection(stored.track, section, media, head)
62
+ track = spliced.track
63
+ for (const src of spliced.unused) {
64
+ const file = mediaFile(deckDir, id, src)
65
+ if (file) await files.remove(file)
66
+ }
67
+ } else {
68
+ track = recordedTrack(head, media)
69
+ }
70
+ await writeTrack(files, deckDir, track)
71
+ ctx.log.info(`lecture ${id}: ${track.events.length} events, ${(trackDuration(track) / 1000).toFixed(1)} s`)
72
+ return jsonResponse({ id, track })
73
+ }
74
+
75
+ return {
76
+ 'recordings': {
77
+ async handle(request) {
78
+ if (request.method !== 'POST') return problemResponse(405, 'POST to start a recording')
79
+ const body: unknown = await request.json().catch(() => ({}))
80
+ const existing = isRecord(body) && typeof body.track === 'string' ? body.track : undefined
81
+ if (existing && !readTrack(deckDir, existing)) return problemResponse(404, `no lecture ${existing}`)
82
+ const id = existing ?? newTrackId(deckDir)
83
+ await writeGitFiles(files, trackDir(deckDir, id)!, policy)
84
+ return jsonResponse({ id })
85
+ },
86
+ },
87
+ 'recordings/*': {
88
+ maxBody: MAX_CHUNK,
89
+ async handle(request, { rest }) {
90
+ const [id = '', action, ...path] = rest.split('/')
91
+ const dir = trackDir(deckDir, id)
92
+ if (!dir) return problemResponse(404, 'no such lecture')
93
+ try {
94
+ if (action === 'media' && request.method === 'POST') {
95
+ const body: unknown = await request.json().catch(() => undefined)
96
+ if (!isRecord(body) || (body.folder !== 'audio' && body.folder !== 'video') || typeof body.section !== 'string' || typeof body.ext !== 'string' || !/^(ogg|webm|mp4|m4a)$/.test(body.ext)) {
97
+ return problemResponse(400, 'expected { folder, section, ext }')
98
+ }
99
+ const stem = typeof body.kind === 'string' ? `${fileStem(body.section)}.${fileStem(body.kind)}` : fileStem(body.section)
100
+ return jsonResponse({ src: freeMediaPath(deckDir, id, body.folder, stem, body.ext) })
101
+ }
102
+ if (action === 'files' && request.method === 'PUT') {
103
+ const file = mediaFile(deckDir, id, decodedPath(path))
104
+ const at = Number(new URL(request.url).searchParams.get('at') ?? '0')
105
+ if (!file || !Number.isSafeInteger(at) || at < 0) return problemResponse(400, 'expected a media file of the lecture and ?at=<position>')
106
+ const size = existsSync(file) ? statSync(file).size : 0
107
+ if (at > size) return problemResponse(400, `?at=${at} is past the end of the file (${size} bytes)`)
108
+ const bytes = new Uint8Array(await request.arrayBuffer())
109
+ if (bytes.byteLength > MAX_CHUNK || at + bytes.byteLength > MAX_FILE) return problemResponse(413, 'chunk too large')
110
+ await files.write(file, bytes, at)
111
+ return jsonResponse({ written: bytes.byteLength })
112
+ }
113
+ if (action === 'finish' && request.method === 'POST') return await finish(id, request)
114
+ } catch (error) {
115
+ ctx.log.error(`lecture ${id}: ${errorMessage(error)}`)
116
+ return problemResponse(500, errorMessage(error))
117
+ }
118
+ return problemResponse(404, 'unknown recording action')
119
+ },
120
+ },
121
+ }
122
+ }
@@ -0,0 +1,38 @@
1
+ import { closeSync, createReadStream, fstatSync } from 'node:fs'
2
+ import { extname } from 'node:path'
3
+ import { Readable } from 'node:stream'
4
+ import { problemResponse } from '@lincyaw/kernel'
5
+ import { openInDeck } from '../store.ts'
6
+
7
+ const TYPES: Record<string, string> = {
8
+ '.ogg': 'audio/ogg',
9
+ '.opus': 'audio/ogg',
10
+ '.webm': 'video/webm',
11
+ '.mp4': 'video/mp4',
12
+ '.m4a': 'audio/mp4',
13
+ '.wav': 'audio/wav',
14
+ '.mp3': 'audio/mpeg',
15
+ }
16
+
17
+ export function serveFile(deckDir: string, file: string | undefined, request: Request): Response {
18
+ const fd = file === undefined ? undefined : openInDeck(deckDir, file)
19
+ if (fd === undefined) return problemResponse(404, 'no such file')
20
+ const { size } = fstatSync(fd)
21
+ const headers = { 'Content-Type': TYPES[extname(file!)] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }
22
+ const body = (start: number, end: number) => Readable.toWeb(createReadStream('', { fd, start, end })) as ReadableStream<Uint8Array>
23
+ const empty = (response: Response) => {
24
+ closeSync(fd)
25
+ return response
26
+ }
27
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
28
+ if (!range || (!range[1] && !range[2])) {
29
+ if (size === 0 || request.method === 'HEAD') return empty(new Response(null, { headers: { ...headers, 'Content-Length': String(size) } }))
30
+ return new Response(body(0, size - 1), { headers: { ...headers, 'Content-Length': String(size) } })
31
+ }
32
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
33
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1
34
+ if (start >= size || start > end) return empty(new Response(null, { status: 416, headers: { ...headers, 'Content-Range': `bytes */${size}` } }))
35
+ const partial = { ...headers, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': String(end - start + 1) }
36
+ if (request.method === 'HEAD') return empty(new Response(null, { status: 206, headers: partial }))
37
+ return new Response(body(start, end), { status: 206, headers: partial })
38
+ }