@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.
- package/LICENSE +202 -0
- package/README.md +66 -2
- package/package.json +68 -3
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +631 -0
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +36 -0
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +781 -0
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +462 -0
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +1025 -0
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- 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
|
+
}
|