@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

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 (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,152 @@
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
2
+ import type { SpeechEngineInfo, VoiceCloneStatus } from '@lincyaw/kernel'
3
+ import { AudioRecording, recordingUnavailable } from '../audio-recording.ts'
4
+ import { AUDIO_RECORDING_MESSAGES } from '../audio-recording-messages.ts'
5
+ import { toWav } from '../audio-wav.ts'
6
+ import type { WavAudio } from '../audio-wav.ts'
7
+ import { Audition } from '../audition.ts'
8
+ import type { AuditionState } from '../audition.ts'
9
+ import { PmButton, PmDialog, PmField, PmInput, PmNotice } from '../components.tsx'
10
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
11
+ import { cloneFailure, VOICE_CLONE_MESSAGES } from '../voice-clone-messages.ts'
12
+
13
+ type Take = WavAudio & { name: string }
14
+
15
+ /** One recording of the clone form: from the microphone or a file, kept as WAV so every provider takes it. */
16
+ function RecordingSlot({ label, hint, value, disabled, onChange }: { label: string, hint: string, value?: Take, disabled: boolean, onChange(value?: Take): void }) {
17
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale)
18
+ const t = VOICE_CLONE_MESSAGES[locale], r = AUDIO_RECORDING_MESSAGES[locale]
19
+ const [recording] = useState(() => new AudioRecording())
20
+ const state = useSyncExternalStore(recording.subscribe, recording.snapshot)
21
+ const [unavailable] = useState(recordingUnavailable)
22
+ const [preparing, setPreparing] = useState(false), [failure, setFailure] = useState<string>()
23
+ const [listening, setListening] = useState<AuditionState>({ status: 'idle' })
24
+ const audition = useRef<Audition>(), file = useRef<HTMLInputElement>(null), mounted = useRef(true)
25
+ useEffect(() => () => { mounted.current = false; recording.dispose(); audition.current?.dispose() }, [recording])
26
+ const accept = async (blob: Blob, name: string) => {
27
+ setPreparing(true); setFailure(undefined); audition.current?.stop()
28
+ try {
29
+ const wav = await toWav(blob)
30
+ if (mounted.current) onChange({ ...wav, name })
31
+ } catch { if (mounted.current) setFailure(t.decodeFailed) }
32
+ finally { if (mounted.current) setPreparing(false) }
33
+ }
34
+ // A finished take becomes the slot's WAV; the take itself is not kept.
35
+ const take = state.candidate?.file
36
+ useEffect(() => {
37
+ if (!take) return
38
+ void accept(take, r.title).finally(() => recording.discard())
39
+ }, [take])
40
+ const capturing = ['requesting', 'recording', 'stopping'].includes(state.phase)
41
+ const start = () => {
42
+ setFailure(undefined)
43
+ void recording.start(async () => {
44
+ workbench.stopAuditions()
45
+ await workbench.pausePlayback()
46
+ return workbench.registerAudioCapture(() => recording.stop())
47
+ })
48
+ }
49
+ const listen = () => {
50
+ if (!value) return
51
+ if (!audition.current) {
52
+ const player = audition.current = new Audition(workbench)
53
+ player.subscribe(() => { if (mounted.current) setListening(player.snapshot()) })
54
+ }
55
+ if (listening.status === 'playing' || listening.status === 'loading') audition.current.stop()
56
+ else void audition.current.play(async () => ({ audio: value.blob }))
57
+ }
58
+ const status = state.phase === 'requesting' ? r.requesting : state.phase === 'recording' ? r.recording : preparing ? t.preparing : value ? `${value.name} · ${t.seconds(value.seconds)}` : t.empty
59
+ return <PmField label={label} hint={hint} group>
60
+ <span className="pm-ui-hint" role="status">{status}</span>
61
+ {(state.error || unavailable) && !capturing && <PmNotice tone="warn">{r.failures[state.error ?? unavailable!] ?? r.failures['recording-failed']}</PmNotice>}
62
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
63
+ <div className="pmw-voice__actions">
64
+ {state.phase === 'recording' ? <PmButton small variant="primary" onClick={() => recording.stop()}>{t.stop}</PmButton>
65
+ : <PmButton small disabled={disabled || capturing || preparing || Boolean(unavailable)} onClick={start}>{t.record}</PmButton>}
66
+ <PmButton small disabled={disabled || capturing || preparing} onClick={() => file.current?.click()}>{t.choose}</PmButton>
67
+ {value && <PmButton small icon="audio" disabled={capturing || preparing} loading={listening.status === 'loading'} onClick={listen}>{listening.status === 'playing' ? t.stopListening : t.listen}</PmButton>}
68
+ {value && <PmButton small disabled={disabled || capturing || preparing} onClick={() => { audition.current?.stop(); onChange(undefined) }}>{t.clear}</PmButton>}
69
+ </div>
70
+ <input ref={file} hidden type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm" onChange={(event) => {
71
+ const chosen = event.currentTarget.files?.[0]
72
+ event.currentTarget.value = ''
73
+ if (chosen) void accept(chosen, chosen.name)
74
+ }} />
75
+ </PmField>
76
+ }
77
+
78
+ interface Props { directory: string, engine: SpeechEngineInfo, lang: string, onUse(voice: string): void, onClose(): void }
79
+
80
+ /**
81
+ * Trains a cloned voice: who speaks, their consent read aloud, a sample; then a confirmation that names the cost,
82
+ * since the provider charges for training; then the training, polled until it ends. The server registers the
83
+ * consent before it sends anything to the provider.
84
+ */
85
+ export function VoiceCloneDialog({ directory, engine, lang, onUse, onClose }: Props) {
86
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = VOICE_CLONE_MESSAGES[locale]
87
+ const volcengine = engine.id === 'volcengine'
88
+ const [voice, setVoice] = useState(''), [speaker, setSpeaker] = useState(''), [scope, setScope] = useState(t.defaultScope)
89
+ const [statement, setStatement] = useState<Take>(), [sample, setSample] = useState<Take>()
90
+ const [confirming, setConfirming] = useState(false), [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>()
91
+ const [status, setStatus] = useState<VoiceCloneStatus>(), [waited, setWaited] = useState(0)
92
+ const request = useRef<AbortController>()
93
+ useEffect(() => () => request.current?.abort(), [])
94
+ const name = voice.trim()
95
+ const nameInvalid = Boolean(name) && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)
96
+ const trained = status?.state === 'ready'
97
+ const locked = busy || trained
98
+ const ready = Boolean(name && !nameInvalid && speaker.trim() && scope.trim() && statement && sample) && !locked
99
+ const train = async () => {
100
+ const controller = new AbortController()
101
+ request.current?.abort(); request.current = controller
102
+ setConfirming(false); setBusy(true); setFailure(undefined); setStatus(undefined); setWaited(0)
103
+ const pause = (ms: number) => new Promise<void>((resolve, reject) => {
104
+ const timer = setTimeout(resolve, ms)
105
+ controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(controller.signal.reason) }, { once: true })
106
+ })
107
+ try {
108
+ let answer = await workbench.client.cloneVoice(directory, { engine: engine.id, voice: name, lang, speaker: speaker.trim(), scope: scope.trim(), sample: sample!.blob, statement: statement!.blob }, controller.signal)
109
+ setStatus(answer)
110
+ const started = Date.now()
111
+ // Training takes seconds to minutes; poll every three seconds for at most ten minutes.
112
+ while (answer.state === 'training' && Date.now() - started < 10 * 60_000) {
113
+ await pause(3000)
114
+ answer = await workbench.client.readCloneStatus(directory, name, engine.id, controller.signal)
115
+ setStatus(answer); setWaited(Date.now() - started)
116
+ }
117
+ } catch (error) { if (!controller.signal.aborted) setFailure(cloneFailure(error, locale)) }
118
+ finally { if (request.current === controller) { request.current = undefined; setBusy(false) } }
119
+ }
120
+ const close = () => { request.current?.abort(); onClose() }
121
+ return <>
122
+ <PmDialog open label={t.title} closeLabel={t.close} description={t.description} onClose={close} footer={<>
123
+ <PmButton onClick={close}>{t.close}</PmButton>
124
+ {trained ? <PmButton variant="primary" onClick={() => onUse(name)}>{t.use}</PmButton>
125
+ : <PmButton variant="primary" loading={busy} disabled={!ready} onClick={() => setConfirming(true)}>{t.train}</PmButton>}
126
+ </>}>
127
+ <div className="pmw-voice__form">
128
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
129
+ {status && <PmNotice tone={status.state === 'ready' ? 'ok' : status.state === 'failed' ? 'danger' : 'accent'}><span role="status">{t.state(status, waited)}</span></PmNotice>}
130
+ <PmInput label={t.voice} hint={t.voiceHint(volcengine)} error={nameInvalid ? t.voiceInvalid : undefined} value={voice} maxLength={128} spellCheck={false} autoComplete="off" disabled={locked} onChange={event => setVoice(event.target.value)} />
131
+ <PmInput label={t.speaker} hint={t.speakerHint} value={speaker} maxLength={80} disabled={locked} onChange={event => setSpeaker(event.target.value)} />
132
+ <PmInput label={t.scope} hint={t.scopeHint} value={scope} maxLength={200} disabled={locked} onChange={event => setScope(event.target.value)} />
133
+ <PmField label={t.statementLabel}><p className="pmw-voice__sample">{t.statementText(speaker.trim() || t.someone, scope.trim() || t.defaultScope)}</p></PmField>
134
+ <RecordingSlot label={t.statement} hint={t.statementHint} value={statement} disabled={locked} onChange={setStatement} />
135
+ <PmField label={t.passageLabel}><p className="pmw-voice__sample">{t.passage}</p></PmField>
136
+ <RecordingSlot label={t.sample} hint={t.sampleHint} value={sample} disabled={locked} onChange={setSample} />
137
+ {sample && (sample.seconds < 10 || sample.seconds > 30) && <PmNotice tone="warn">{t.sampleLength(sample.seconds)}</PmNotice>}
138
+ </div>
139
+ </PmDialog>
140
+ <PmDialog open={confirming} label={t.confirmTitle} closeLabel={t.cancel} description={t.confirmHint} onClose={() => setConfirming(false)} footer={<>
141
+ <PmButton onClick={() => setConfirming(false)}>{t.cancel}</PmButton>
142
+ <PmButton variant="primary" onClick={() => void train()}>{t.confirm}</PmButton>
143
+ </>}>
144
+ <div className="pmw-voice__form">
145
+ <PmField label={t.voice}><span>{name}</span></PmField>
146
+ <PmField label={t.speaker}><span>{speaker.trim()}</span></PmField>
147
+ <PmField label={t.scope}><span>{scope.trim()}</span></PmField>
148
+ <PmNotice tone="warn">{t.costs(engine.provider, volcengine)}</PmNotice>
149
+ </div>
150
+ </PmDialog>
151
+ </>
152
+ }
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { VoicePreviewQuote } from '@lincyaw/kernel'
3
+ import { Audition } from '../audition.ts'
4
+ import type { AuditionState } from '../audition.ts'
5
+ import { PmButton, PmDialog, PmField, PmNotice } from '../components.tsx'
6
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
7
+ import { VOICE_MESSAGES, voiceFailure } from '../voice-messages.ts'
8
+ import { voiceSnapshotCurrent } from '../voice-snapshot.ts'
9
+ import type { VoiceSnapshot } from '../voice-snapshot.ts'
10
+
11
+ /** A quote authorizes one explicit audition, never a setting change or automatic synthesis. */
12
+ export function VoicePreviewDialog({ quote, snapshot, onClose, onRecheck }: { quote: VoicePreviewQuote, snapshot: VoiceSnapshot, onClose(): void, onRecheck?(): void }) {
13
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = VOICE_MESSAGES[locale]
14
+ const current = useWorkbenchState(() => voiceSnapshotCurrent(workbench, snapshot))
15
+ const audition = useRef<Audition>(), owner = useRef(0), closed = useRef(false)
16
+ const [state, setState] = useState<AuditionState>({ status: 'idle' }), [error, setError] = useState<string>(), [invalid, setInvalid] = useState(false)
17
+ const matches = quote.revision === snapshot.revision && quote.source.documentId === snapshot.source.documentId && quote.source.version === snapshot.source.version && quote.source.fingerprint === snapshot.source.fingerprint
18
+
19
+ useEffect(() => {
20
+ const version = ++owner.current, player = new Audition(workbench)
21
+ closed.current = false; audition.current = player
22
+ setState(player.snapshot()); setError(undefined); setInvalid(false)
23
+ const stop = player.subscribe(() => { if (owner.current === version && !closed.current) setState(player.snapshot()) })
24
+ return () => { owner.current++; stop(); player.dispose(); if (audition.current === player) audition.current = undefined }
25
+ }, [workbench, quote, snapshot])
26
+
27
+ useEffect(() => {
28
+ if (current && matches) return
29
+ setInvalid(true); audition.current?.stop()
30
+ }, [current, matches])
31
+
32
+ const close = () => { if (closed.current) return; closed.current = true; owner.current++; audition.current?.dispose(); onClose() }
33
+ const recheck = () => { audition.current?.stop(); onRecheck?.() }
34
+ const confirm = () => {
35
+ const player = audition.current, version = owner.current
36
+ if (!player || closed.current || invalid || !matches || !voiceSnapshotCurrent(workbench, snapshot)) return
37
+ setError(undefined)
38
+ void player.play(async signal => {
39
+ try {
40
+ if (!voiceSnapshotCurrent(workbench, snapshot)) throw new Error('voice-context-changed')
41
+ const answer = await workbench.client.previewVoice(snapshot.directory, { revision: quote.revision, source: quote.source, voice: quote.voice, mode: quote.mode, ...quote.target ? { target: quote.target } : {}, quote: quote.quote, confirmed: true }, signal)
42
+ signal.throwIfAborted()
43
+ if (owner.current !== version || closed.current || !voiceSnapshotCurrent(workbench, snapshot) || answer.revision !== quote.revision
44
+ || answer.source.documentId !== quote.source.documentId || answer.source.version !== quote.source.version || answer.source.fingerprint !== quote.source.fingerprint) throw new Error('voice-context-changed')
45
+ return { audio: answer.audio }
46
+ } catch (failure) {
47
+ if (owner.current === version && !closed.current && !signal.aborted) {
48
+ setError(voiceFailure(failure, locale))
49
+ if (failure instanceof Error && ['voice-context-changed', 'voice-source-changed', 'voice-source-unavailable', 'voice-revision-changed', 'voice-preview-changed'].includes(failure.message)) setInvalid(true)
50
+ }
51
+ throw failure
52
+ }
53
+ })
54
+ }
55
+ const changed = invalid || !current || !matches, active = state.status === 'loading' || state.status === 'playing'
56
+ return <PmDialog open label={quote.mode === 'step' ? t.currentScript : t.standard} description={t.previewHint} closeLabel={t.close} onClose={close} footer={<>
57
+ <PmButton small onClick={close}>{t.close}</PmButton>
58
+ {changed && onRecheck && <PmButton small onClick={recheck}>{t.recheck}</PmButton>}
59
+ {active ? <PmButton small onClick={() => audition.current?.stop()}>{t.stop}</PmButton>
60
+ : <PmButton small variant="accent" disabled={changed || !audition.current} onClick={confirm}>{t.confirmPreview}</PmButton>}
61
+ </>}>
62
+ <div className="pmw-operation">
63
+ <PmField label={t.provider}><span>{quote.provider}</span></PmField>
64
+ <PmField label={t.currentVoice}><span>{quote.voice.voice}</span></PmField>
65
+ {quote.target && <PmField label={t.scope}><span>{snapshot.ir.sections.find(page => page.id === quote.target?.section)?.title} · {t.step(snapshot.ir.sections.findIndex(page => page.id === quote.target?.section) + 1, quote.target.step + 1)}</span></PmField>}
66
+ <PmField label={quote.mode === 'step' ? t.currentScript : t.sampleText}><p className="pmw-voice__sample">{quote.text}</p></PmField>
67
+ <PmNotice>{quote.cached ? t.cachedPreview : t.disclosure(quote.provider, quote.local)}</PmNotice>
68
+ {changed && <PmNotice tone="warn">{t.changed}</PmNotice>}
69
+ {state.status === 'loading' && <PmNotice>{t.auditionLoading}</PmNotice>}
70
+ {state.status === 'playing' && <PmNotice tone="accent">{t.auditioning}</PmNotice>}
71
+ {(error || state.status === 'failed') && <PmNotice tone="danger">{error ?? voiceFailure(new Error(state.error), locale)}</PmNotice>}
72
+ </div>
73
+ </PmDialog>
74
+ }
@@ -0,0 +1,30 @@
1
+ import type { TeachingDocument } from '@lincyaw/kernel'
2
+ import { PmButton, PmField, PmSegmented } from '../components.tsx'
3
+ import { useWorkbenchState } from '../context.tsx'
4
+ import type { DocumentVoiceScope } from '../document-voices.ts'
5
+ import { PRODUCTION_MESSAGES } from '../production-messages.ts'
6
+ import { VOICE_MESSAGES } from '../voice-messages.ts'
7
+
8
+ /** Scope selection uses authored IDs and stays available before runtime compilation. */
9
+ export function VoiceScopeField({ document, stepId, value, disabled, onChange }: { document: TeachingDocument, stepId: string, value: DocumentVoiceScope, disabled?: boolean, onChange(value: DocumentVoiceScope): void }) {
10
+ const locale = useWorkbenchState(state => state.locale), t = VOICE_MESSAGES[locale], p = PRODUCTION_MESSAGES[locale]
11
+ const sectionId = document.steps[stepId]?.sectionId
12
+ return <>
13
+ <PmField label={t.scope} group>
14
+ <PmSegmented label={t.scope} value={value.kind} options={(['step', 'steps', 'page', 'document'] as const).map(kind => ({ value: kind, label: p.scopeShort[kind] }))} disabled={disabled} onChange={kind => {
15
+ if (kind === 'document') onChange({ kind })
16
+ else if (kind === 'page' && sectionId) onChange({ kind, sectionId })
17
+ else if (kind === 'step') onChange({ kind, stepId })
18
+ else if (kind === 'steps') onChange({ kind, stepIds: [stepId] })
19
+ }} />
20
+ </PmField>
21
+ {value.kind === 'steps' && <PmField label={t.selectedSteps} group hint={t.selected(value.stepIds.length)} error={value.stepIds.length ? undefined : p.selectSteps}>
22
+ <div className="pmw-voice__steps">
23
+ {document.sectionOrder.flatMap((id, page) => document.sections[id]!.stepOrder.map((selected, index) => {
24
+ const checked = value.stepIds.includes(selected)
25
+ return <PmButton key={selected} small aria-label={`${document.sections[id]!.title} · ${t.step(page + 1, index + 1)}`} aria-pressed={checked} disabled={disabled} onClick={() => onChange({ kind: 'steps', stepIds: checked ? value.stepIds.filter(id => id !== selected) : [...value.stepIds, selected] })}>{page + 1} · {index + 1}</PmButton>
26
+ }))}
27
+ </div>
28
+ </PmField>}
29
+ </>
30
+ }
@@ -0,0 +1,216 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { speechEngineId } from '@lincyaw/kernel'
3
+ import type { TeachingDocument, VoiceoverJob, VoiceoverSnapshot, VoiceScope } from '@lincyaw/kernel'
4
+ import { PmButton, PmDialog, PmField, PmNotice } from '../components.tsx'
5
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { documentVoiceStepIds, resolveDocumentStepVoice } from '../document-voices.ts'
7
+ import type { DocumentVoiceScope } from '../document-voices.ts'
8
+ import { VOICE_MESSAGES, voiceFailure } from '../voice-messages.ts'
9
+ import { prepareVoiceSnapshot, runtimeVoiceScope, voiceSnapshotCurrent } from '../voice-snapshot.ts'
10
+ import type { VoiceSnapshot } from '../voice-snapshot.ts'
11
+ import { isAgentChanging } from '../workbench.ts'
12
+ import { VoiceScopeField } from './VoiceScopeField.tsx'
13
+
14
+ interface CheckedVoice { snapshot: VoiceSnapshot, document: TeachingDocument }
15
+ interface OwnedJob { directory: string }
16
+ const NO_JOBS: VoiceoverJob[] = []
17
+ const active = (job: VoiceoverJob) => ['queued', 'running', 'cancelling'].includes(job.state)
18
+ const sameSource = (a: VoiceoverSnapshot['source'], b: VoiceoverSnapshot['source']) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
19
+ const jobStamp = (jobs: VoiceoverJob[]) => jobs.map(job => `${job.id}:${job.sequence}`).join(',')
20
+ function coversScope(job: VoiceScope, selected: VoiceScope): boolean {
21
+ if (job.kind === 'document') return true
22
+ if (selected.kind === 'document') return false
23
+ if (selected.kind === 'page') return job.kind === 'page' && job.section === selected.section
24
+ const targets = selected.kind === 'step' ? [selected.target] : selected.targets
25
+ if (job.kind === 'page') return targets.every(target => target.section === job.section)
26
+ const generated = job.kind === 'step' ? [job.target] : job.targets
27
+ return targets.every(target => generated.some(value => value.section === target.section && value.step === target.step && value.binding === target.binding))
28
+ }
29
+
30
+ /** Visible author jobs use acknowledged source versions; polling never starts generation. */
31
+ export function VoiceoverDialog({ stepId, onClose }: { stepId: string, onClose(): void }) {
32
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = VOICE_MESSAGES[locale]
33
+ const source = useWorkbenchState(state => state.authoring)
34
+ const serviceVersion = useWorkbenchState(state => state.voiceServiceVersion)
35
+ const jobs = useWorkbenchState(state => state.voiceoverJobs) ?? NO_JOBS
36
+ const jobsFailure = useWorkbenchState(state => state.voiceoverJobsFailure)
37
+ const readOnly = useWorkbenchState(state => Boolean(state.authoring?.loading || state.authoring?.composing) || isAgentChanging(state))
38
+ const [scope, setScope] = useState<DocumentVoiceScope>({ kind: 'step', stepId })
39
+ const [checked, setChecked] = useState<CheckedVoice>(), [status, setStatus] = useState<VoiceoverSnapshot>()
40
+ const [loading, setLoading] = useState(true), [submitting, setSubmitting] = useState(false), [cancelling, setCancelling] = useState<string>()
41
+ const [changed, setChanged] = useState(false), [error, setError] = useState<string>(), [attempt, setAttempt] = useState(0)
42
+ const life = useRef<AbortController>(), read = useRef<AbortController>(), mutation = useRef<AbortController>(), generation = useRef(0), starting = useRef(false)
43
+ const checkedRef = useRef<CheckedVoice>(), statusJobs = useRef(''), owned = useRef(new Map<string, OwnedJob>()), closing = useRef(false), directoryRef = useRef(workbench.getState().document)
44
+
45
+ const cancelOwned = () => {
46
+ for (const [id, { directory }] of owned.current) {
47
+ const job = workbench.getState().voiceoverJobs?.find(value => value.id === id)
48
+ if (!job || active(job)) void workbench.client.cancelVoiceover(directory, id, AbortSignal.timeout(5000)).catch(() => {})
49
+ }
50
+ owned.current.clear()
51
+ }
52
+ useEffect(() => {
53
+ life.current = new AbortController(); closing.current = false
54
+ directoryRef.current = workbench.getState().document
55
+ setScope({ kind: 'step', stepId })
56
+ return () => { closing.current = true; generation.current++; life.current?.abort(); read.current?.abort(); mutation.current?.abort(); cancelOwned() }
57
+ }, [workbench, stepId, source?.identity])
58
+ useEffect(() => workbench.observeVoiceoverJobs(), [workbench, source?.identity])
59
+
60
+ useEffect(() => {
61
+ const controller = new AbortController(), version = ++generation.current
62
+ read.current?.abort(); read.current = controller
63
+ const signal = AbortSignal.any([controller.signal, life.current!.signal])
64
+ checkedRef.current = undefined; setChecked(undefined); setStatus(undefined); setLoading(true); setError(undefined)
65
+ void (async () => {
66
+ try {
67
+ workbench.stopAuditions()
68
+ const snapshot = await prepareVoiceSnapshot(workbench, stepId, signal)
69
+ const document = workbench.getDocument(snapshot.identity)
70
+ if (!document) throw new Error('voice-context-changed')
71
+ const answer = await workbench.client.readVoiceover(snapshot.directory, signal)
72
+ signal.throwIfAborted()
73
+ if (generation.current !== version || closing.current) return
74
+ if (!voiceSnapshotCurrent(workbench, snapshot) || answer.revision !== snapshot.revision || !sameSource(answer.source, snapshot.source)) throw new Error('voice-context-changed')
75
+ const value = { snapshot, document }
76
+ checkedRef.current = value; statusJobs.current = jobStamp(answer.jobs)
77
+ setChecked(value); setStatus(answer); setChanged(false)
78
+ } catch (failure) {
79
+ if (generation.current === version && !signal.aborted && !closing.current) setError(voiceFailure(failure, locale))
80
+ } finally { if (generation.current === version && !signal.aborted && !closing.current) setLoading(false) }
81
+ })()
82
+ return () => controller.abort()
83
+ }, [workbench, stepId, attempt, source?.identity, serviceVersion])
84
+
85
+ useEffect(() => workbench.subscribe(() => {
86
+ const value = checkedRef.current
87
+ if (!value || voiceSnapshotCurrent(workbench, value.snapshot)) return
88
+ mutation.current?.abort(); starting.current = false; setSubmitting(false); setChanged(true)
89
+ }), [workbench])
90
+
91
+ const hasActive = jobs.some(active)
92
+ useEffect(() => {
93
+ const value = checkedRef.current, stamp = jobStamp(jobs)
94
+ if (!value || !voiceSnapshotCurrent(workbench, value.snapshot) || stamp === statusJobs.current) return
95
+ const controller = new AbortController(), signal = AbortSignal.any([controller.signal, life.current!.signal])
96
+ void (async () => {
97
+ try {
98
+ const answer = await workbench.client.readVoiceover(value.snapshot.directory, signal)
99
+ signal.throwIfAborted()
100
+ if (closing.current || checkedRef.current !== value) return
101
+ if (voiceSnapshotCurrent(workbench, value.snapshot) && answer.revision === value.snapshot.revision && sameSource(answer.source, value.snapshot.source)) {
102
+ statusJobs.current = stamp; setStatus(answer)
103
+ }
104
+ else setChanged(true)
105
+ } catch (failure) { if (!signal.aborted && !closing.current) setError(voiceFailure(failure, locale)) }
106
+ })()
107
+ return () => controller.abort()
108
+ }, [workbench, jobs, checked])
109
+
110
+ const selection = useMemo(() => {
111
+ if (!checked || !status) return undefined
112
+ try {
113
+ const ids = documentVoiceStepIds(checked.document, scope), wanted = new Set(ids)
114
+ const steps = status.steps.filter(value => {
115
+ const page = checked.snapshot.ir.sections.find(page => page.id === value.target.section)
116
+ return page?.stepIds?.[value.target.step] !== undefined && wanted.has(page.stepIds[value.target.step]!)
117
+ })
118
+ const pages = new Set(ids.map(id => checked.document.steps[id]!.sectionId))
119
+ const unbound = scope.kind === 'page' || scope.kind === 'document' ? (status.unboundByPage ?? []).filter(value => pages.has(value.section)) : []
120
+ const units = [...steps, ...unbound]
121
+ const cached = units.reduce((sum, value) => sum + value.cached, 0)
122
+ const missing = units.filter(value => value.state === 'missing').reduce((sum, value) => sum + value.utterances - value.cached, 0)
123
+ const blocked = units.filter(value => value.state === 'blocked').reduce((sum, value) => sum + value.utterances - value.cached, 0)
124
+ const providers = status.capability.providers.filter(provider => ids.some(id => {
125
+ const value = resolveDocumentStepVoice(checked.document, id).value
126
+ return value?.type === 'tts' && provider.id === speechEngineId(value.voice.engine ?? status.capability.defaultEngine ?? '')
127
+ }))
128
+ const text = ids.flatMap(id => checked.document.steps[id]!.narrationIds.map(narrationId => checked.document.narrations[narrationId]!.content.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.spoken : '').join(''))).join('\n\n')
129
+ const voices = [...new Map(ids.flatMap(id => { const value = resolveDocumentStepVoice(checked.document, id).value; return value?.type === 'tts' ? [[JSON.stringify(value.voice), value.voice] as const] : [] })).values()]
130
+ const silent = ids.filter(id => resolveDocumentStepVoice(checked.document, id).value?.type === 'none').length
131
+ const unset = ids.some(id => resolveDocumentStepVoice(checked.document, id).value === undefined)
132
+ const reasons = [...new Set(units.filter(value => value.state === 'blocked').flatMap(value => value.reason && !(value.reason === 'voice-unset' && silent && !unset) ? [value.reason] : []))]
133
+ return { cached, missing, blocked, providers, voices, silent, reasons, text, hasUnbound: !unbound.length && (status.unboundByPage ?? []).some(value => pages.has(value.section) && value.utterances > 0) }
134
+ } catch { return undefined }
135
+ }, [checked, status, scope])
136
+ const hasScopeJob = useMemo(() => {
137
+ if (!checked) return false
138
+ try {
139
+ const selected = runtimeVoiceScope(checked.snapshot, scope)
140
+ return jobs.some(job => (active(job) || job.state === 'done' && job.completed > 0)
141
+ && sameSource(job.source, checked.snapshot.source) && job.revision === checked.snapshot.revision && coversScope(job.scope, selected))
142
+ } catch { return false }
143
+ }, [checked, jobs, scope])
144
+ const chooseScope = (value: DocumentVoiceScope) => { mutation.current?.abort(); starting.current = false; setSubmitting(false); setScope(value); setError(undefined) }
145
+ const close = () => {
146
+ if (closing.current) return
147
+ closing.current = true; generation.current++; life.current?.abort(); read.current?.abort(); mutation.current?.abort(); cancelOwned(); onClose()
148
+ }
149
+ const start = async () => {
150
+ const value = checkedRef.current
151
+ if (!value || !selection?.missing || !selection.providers.length || starting.current || changed || readOnly || closing.current || !voiceSnapshotCurrent(workbench, value.snapshot)) return
152
+ const controller = new AbortController(), version = generation.current
153
+ mutation.current?.abort(); mutation.current = controller
154
+ const signal = AbortSignal.any([controller.signal, life.current!.signal])
155
+ starting.current = true; setSubmitting(true); setError(undefined)
156
+ try {
157
+ workbench.stopAuditions()
158
+ await workbench.pausePlayback(); signal.throwIfAborted()
159
+ if (!voiceSnapshotCurrent(workbench, value.snapshot)) throw new Error('voice-context-changed')
160
+ const runtimeScope = runtimeVoiceScope(value.snapshot, scope)
161
+ const job = await workbench.client.startVoiceover(value.snapshot.directory, { revision: value.snapshot.revision, source: value.snapshot.source, scope: runtimeScope, confirmed: true }, signal)
162
+ if (closing.current || signal.aborted || generation.current !== version) { if (active(job)) void workbench.client.cancelVoiceover(value.snapshot.directory, job.id, AbortSignal.timeout(5000)).catch(() => {}); return }
163
+ if (!sameSource(job.source, value.snapshot.source) || job.revision !== value.snapshot.revision || !voiceSnapshotCurrent(workbench, value.snapshot)) throw new Error('voice-context-changed')
164
+ owned.current.set(job.id, { directory: value.snapshot.directory })
165
+ workbench.rememberVoiceoverJob(value.snapshot.directory, job)
166
+ } catch (failure) { if (!signal.aborted && !closing.current && generation.current === version) setError(voiceFailure(failure, locale)) }
167
+ finally { if (mutation.current === controller) { starting.current = false; if (!closing.current) setSubmitting(false) } }
168
+ }
169
+ const cancel = async (job: VoiceoverJob) => {
170
+ const directory = owned.current.get(job.id)?.directory ?? checkedRef.current?.snapshot.directory ?? directoryRef.current
171
+ if (directory === undefined || cancelling || closing.current) return
172
+ const signal = life.current!.signal
173
+ setCancelling(job.id); setError(undefined)
174
+ try {
175
+ const answer = await workbench.client.cancelVoiceover(directory, job.id, signal)
176
+ signal.throwIfAborted()
177
+ if (closing.current) return
178
+ workbench.rememberVoiceoverJob(directory, answer)
179
+ } catch (failure) { if (!signal.aborted && !closing.current) setError(voiceFailure(failure, locale)) }
180
+ finally { if (!signal.aborted && !closing.current) setCancelling(undefined) }
181
+ }
182
+ const unavailable = !status?.capability.available && (!selection?.cached || Boolean(selection.missing || selection.blocked))
183
+ const failure = error ?? (jobsFailure ? voiceFailure(new Error(jobsFailure), locale) : undefined)
184
+ const capabilityFailure = unavailable && selection?.voices.length && status ? voiceFailure(new Error(status.capability.reason), locale) : undefined
185
+ const warnings = [...new Set([...(capabilityFailure ? [capabilityFailure] : []), ...(selection?.reasons ?? []).map(reason => voiceFailure(new Error(reason), locale))])].filter(message => message !== failure)
186
+ const cancelOnClose = submitting || [...owned.current.keys()].some(id => { const job = jobs.find(value => value.id === id); return !job || active(job) })
187
+ const closeLabel = cancelOnClose ? t.cancelAndClose : t.close
188
+ return <PmDialog open label={t.generation} closeLabel={closeLabel} onClose={close} footer={<>
189
+ <PmButton small onClick={close}>{closeLabel}</PmButton>
190
+ <PmButton small loading={loading} disabled={submitting} onClick={() => setAttempt(value => value + 1)}>{changed ? t.recheck : t.refresh}</PmButton>
191
+ <PmButton small variant="accent" loading={submitting} disabled={loading || changed || readOnly || hasActive || !selection?.missing || !selection.providers.length || !status?.capability.available} onClick={() => void start()}>{t.confirmGenerate}</PmButton>
192
+ </>}>
193
+ <div className="pmw-operation">
194
+ {(source?.content || checked?.document) && <VoiceScopeField document={checked?.document ?? source!.content!} stepId={stepId} value={scope} disabled={loading || submitting || readOnly} onChange={chooseScope} />}
195
+ {loading && <PmNotice>{t.refreshing}</PmNotice>}
196
+ {changed && <PmNotice tone="warn">{t.changed}</PmNotice>}
197
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
198
+ {status && selection && <>
199
+ {(selection.missing > 0 || selection.blocked > 0) && <PmNotice>{t.generationSummary(selection.missing, selection.blocked)}</PmNotice>}
200
+ {!changed && !hasScopeJob && !selection.missing && !selection.blocked && selection.cached > 0 && <PmNotice tone="ok">{t.cacheReady}</PmNotice>}
201
+ {!selection.missing && !selection.cached && !selection.blocked && <PmNotice>{t.noScript}</PmNotice>}
202
+ {selection.hasUnbound && <PmNotice tone="warn">{t.unboundScript}</PmNotice>}
203
+ {selection.silent > 0 && <PmNotice>{t.silentScope(selection.silent)}</PmNotice>}
204
+ {warnings.map(message => <PmNotice tone="warn" key={message}>{message}</PmNotice>)}
205
+ {selection.voices.map(voice => <PmField key={JSON.stringify(voice)} label={t.currentVoice}><span>{voice.voice}{voice.engine ? ` · ${voice.engine}` : ''}{voice.rate !== undefined ? ` · ${t.rate}: ${voice.rate}` : ''}{voice.style ? ` · ${t.style}: ${voice.style}` : ''}</span></PmField>)}
206
+ {selection.missing > 0 && selection.providers.map(provider => <PmNotice key={provider.id}>{t.disclosure(provider.provider, provider.local)}</PmNotice>)}
207
+ {selection.text && <PmField label={t.generationScript}><p className="pmw-voice__sample">{selection.text}</p></PmField>}
208
+ </>}
209
+ {jobs.map(job => <div className="pmw-voice__status" key={job.id}>
210
+ <span>{t.scopes[job.scope.kind]} · {t.jobs[job.state]} · {t.progress(job.completed, job.total, job.failed)}{source?.version !== job.source.version || source?.fingerprint !== job.source.fingerprint ? ` · ${t.oldVersion}` : ''}</span>
211
+ {active(job) && <PmButton small loading={cancelling === job.id} disabled={job.state === 'cancelling'} onClick={() => void cancel(job)}>{t.cancelJob}</PmButton>}
212
+ {job.error && <span>{voiceFailure(new Error(job.error), locale)}</span>}
213
+ </div>)}
214
+ </div>
215
+ </PmDialog>
216
+ }
package/src/places.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { irBlocks } from '@lincyaw/kernel'
2
+ import type { IRDocument, PreviewAnchor, ReviewChange, SourcePos, TeachingDocument } from '@lincyaw/kernel'
3
+
4
+ const CHANGED_PLACES = 100
5
+
6
+ /** A place in the document (a section, or a block of it) and its source. */
7
+ export interface PlaceWithSource {
8
+ section: string
9
+ block?: string
10
+ source: SourcePos
11
+ }
12
+
13
+ /** The smallest section or block whose source spans the line of the file; a block wins over a section of the same span. */
14
+ export function findPlaceAtLine(doc: IRDocument, file: string, line: number): PlaceWithSource | undefined {
15
+ let found: PlaceWithSource | undefined
16
+ let span = Number.POSITIVE_INFINITY
17
+ for (const section of doc.sections) {
18
+ const places: PlaceWithSource[] = [{ section: section.id, source: section.source }, ...[...irBlocks(section.blocks)].map(block => ({ section: section.id, block: block.id, source: block.source }))]
19
+ for (const place of places) {
20
+ const { source } = place
21
+ const end = source.endLine ?? source.line
22
+ if (source.file !== file || line < source.line || line > end) continue
23
+ if (end - source.line < span || (end - source.line === span && place.block)) {
24
+ found = place
25
+ span = end - source.line
26
+ }
27
+ }
28
+ }
29
+ return found
30
+ }
31
+
32
+ /** The source of a section, or of a block in it; a block that is not in the section gives the section's. */
33
+ export function findPlaceSource(doc: IRDocument, section: string, block?: string): PlaceWithSource | undefined {
34
+ const found = doc.sections.find(s => s.id === section)
35
+ if (!found) return undefined
36
+ if (!block) return { section, source: found.source }
37
+ const inner = [...irBlocks(found.blocks)].find(b => b.id === block)
38
+ return inner ? { section, block, source: inner.source } : { section, source: found.source }
39
+ }
40
+
41
+ /** The lines of the new file a unified diff added or changed; a removal marks the line now in its place. */
42
+ function changedLines(diff: string): Set<number> {
43
+ const lines = new Set<number>()
44
+ let line = 0
45
+ for (const text of diff.split('\n')) {
46
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text)
47
+ if (hunk) line = Number(hunk[1])
48
+ else if (line === 0) continue
49
+ else if (text.startsWith('+')) lines.add(line++)
50
+ else if (text.startsWith('-')) lines.add(line)
51
+ else if (text.startsWith(' ')) line++
52
+ }
53
+ return lines
54
+ }
55
+
56
+ /** The blocks (or sections) of the document whose source the changes touched, in the order of the changes. */
57
+ export function changedPlaces(doc: IRDocument, changes: readonly ReviewChange[]): PreviewAnchor[] {
58
+ const places = new Map<string, PreviewAnchor>()
59
+ for (const change of changes) {
60
+ if (!change.diff || change.kind === 'deleted') continue
61
+ for (const line of changedLines(change.diff)) {
62
+ const place = findPlaceAtLine(doc, change.path, line)
63
+ if (place) places.set(`${place.section}\0${place.block ?? ''}`, { section: place.section, ...place.block ? { block: place.block } : {} })
64
+ }
65
+ }
66
+ return [...places.values()].slice(0, CHANGED_PLACES)
67
+ }
68
+
69
+ /**
70
+ * The places that differ between two versions of a document: each block whose content changed or that is new (with
71
+ * its page), each page whose title changed or that is new, and the block of each step whose narration or settings
72
+ * changed, at most CHANGED_PLACES.
73
+ */
74
+ export function changedBlocks(before: TeachingDocument, after: TeachingDocument): PreviewAnchor[] {
75
+ const places: PreviewAnchor[] = []
76
+ const visit = (section: string, ids: readonly string[]) => {
77
+ for (const id of ids) {
78
+ const block = after.blocks[id]
79
+ if (!block) continue
80
+ if (JSON.stringify(block) !== JSON.stringify(before.blocks[id])) places.push({ section, block: id })
81
+ if ('children' in block && Array.isArray(block.children)) visit(section, block.children as string[])
82
+ }
83
+ }
84
+ for (const id of after.sectionOrder) {
85
+ const section = after.sections[id]
86
+ if (!section) continue
87
+ if (before.sections[id]?.title !== section.title) places.push({ section: id })
88
+ visit(id, section.children)
89
+ }
90
+ // A changed step, or a changed narration of it, shows at the block the step plays (else at its page).
91
+ const seen = new Set(places.map(place => `${place.section}\0${place.block ?? ''}`))
92
+ for (const step of Object.values(after.steps)) {
93
+ const narrated = step.narrationIds.some(id => JSON.stringify(after.narrations[id]) !== JSON.stringify(before.narrations[id]))
94
+ if (!narrated && JSON.stringify(step) === JSON.stringify(before.steps[step.id])) continue
95
+ // the component the step plays, where its narration shows, else any block it shows
96
+ const block = step.visuals.find(visual => after.blocks[visual.blockId]?.type === 'component')?.blockId ?? step.visuals.find(visual => visual.visible)?.blockId ?? step.visuals[0]?.blockId
97
+ const place = { section: step.sectionId, ...block ? { block } : {} }
98
+ const key = `${place.section}\0${place.block ?? ''}`
99
+ if (!seen.has(key)) { seen.add(key); places.push(place) }
100
+ }
101
+ return places.slice(0, CHANGED_PLACES)
102
+ }