@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/presmium-lecture.mjs +12 -0
- package/package.json +68 -3
- package/src/client/ChapterList.tsx +50 -0
- package/src/client/LectureView.css +21 -0
- package/src/client/LectureView.tsx +84 -0
- package/src/client/PlayLectureButton.tsx +21 -0
- package/src/client/PlayerPanel.css +120 -0
- package/src/client/PlayerPanel.tsx +194 -0
- package/src/client/RecorderPanel.css +46 -0
- package/src/client/RecorderPanel.tsx +282 -0
- package/src/client/TranscriptList.tsx +28 -0
- package/src/client/clock.ts +169 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/media.ts +86 -0
- package/src/client/playback.ts +140 -0
- package/src/client/player.ts +202 -0
- package/src/client/plugin.ts +33 -0
- package/src/client/pointer-ink.ts +143 -0
- package/src/client/production-playback.ts +188 -0
- package/src/client/recorder.ts +245 -0
- package/src/client/source.ts +109 -0
- package/src/client/state.ts +45 -0
- package/src/client/viewer.ts +37 -0
- package/src/domain/audience.ts +108 -0
- package/src/domain/caption-timing.ts +59 -0
- package/src/domain/captions.ts +59 -0
- package/src/domain/config.ts +67 -0
- package/src/domain/drift.ts +159 -0
- package/src/domain/editing.ts +112 -0
- package/src/domain/format.ts +13 -0
- package/src/domain/index.ts +50 -0
- package/src/domain/messages.ts +324 -0
- package/src/domain/narration.ts +160 -0
- package/src/domain/plan.ts +319 -0
- package/src/domain/production-audio.ts +39 -0
- package/src/domain/production.ts +136 -0
- package/src/domain/recording.ts +130 -0
- package/src/domain/script-track.ts +176 -0
- package/src/domain/timing.ts +107 -0
- package/src/domain/track.ts +302 -0
- package/src/domain/translation.ts +34 -0
- package/src/domain/vtt.ts +68 -0
- package/src/domain/words.ts +114 -0
- package/src/domain/writeback.ts +129 -0
- package/src/node/audio-authoring.ts +53 -0
- package/src/node/check.ts +93 -0
- package/src/node/cli.ts +82 -0
- package/src/node/clips.ts +10 -0
- package/src/node/engines.ts +43 -0
- package/src/node/export.ts +331 -0
- package/src/node/ffmpeg.ts +76 -0
- package/src/node/latest-ir.ts +54 -0
- package/src/node/plan.ts +35 -0
- package/src/node/presmium.ts +59 -0
- package/src/node/production-assets.ts +112 -0
- package/src/node/production.ts +102 -0
- package/src/node/renderer.ts +182 -0
- package/src/node/roles.ts +23 -0
- package/src/node/routes/production.ts +51 -0
- package/src/node/routes/recordings.ts +122 -0
- package/src/node/routes/serve-file.ts +38 -0
- package/src/node/routes/served-deck.ts +61 -0
- package/src/node/routes/server.ts +12 -0
- package/src/node/routes/tracks.ts +98 -0
- package/src/node/routes/voiceover.ts +23 -0
- package/src/node/store.ts +192 -0
- package/src/node/synthesize.ts +190 -0
- package/src/node/tools.ts +170 -0
- package/src/node/transcribe.ts +53 -0
- package/src/node/voice-generation.ts +15 -0
- package/src/node/voice-source.ts +34 -0
- package/src/node/voiceover-execution.ts +223 -0
|
@@ -0,0 +1,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
|
+
}
|