@lincyaw/workbench 0.1.0 → 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/README.md +40 -8
- package/package.json +27 -22
- 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 +547 -90
- 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 +33 -43
- 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 +751 -203
- 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 +456 -44
- 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 +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- 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 +757 -361
- 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
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { keyBelongsToTarget, PRODUCTION_PLAYBACK_RATES, sameProductionBinding, watchFocusSource } from '@lincyaw/kernel'
|
|
3
|
+
import type { ProductionPlaybackCommand } from '@lincyaw/kernel'
|
|
4
|
+
import { PmBadge, PmButton, PmCard, PmDialog, PmEmpty, PmIconButton, PmNotice, PmPanelHeader, PmPopover, PmSearchList, PmSelect, PmSegmented, PmSplitGroup, PmSplitHandle, PmSplitPanel, PmTabs, PmTabPanel, PmToolbar, PmToolbarButton } from '../components.tsx'
|
|
5
|
+
import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
6
|
+
import { DocumentStepNarration } from '../document-editor/DocumentStepNarration.tsx'
|
|
7
|
+
import { PRODUCTION_MESSAGES, productionFailure } from '../production-messages.ts'
|
|
8
|
+
import { SHORTCUTS } from '../shortcuts.ts'
|
|
9
|
+
import { isAgentChanging } from '../workbench.ts'
|
|
10
|
+
import type { PlaybackKey } from '../workbench.ts'
|
|
11
|
+
import { DocumentVoiceField } from './DocumentVoiceField.tsx'
|
|
12
|
+
import { ExportPanel } from './ExportPanel.tsx'
|
|
13
|
+
import { PreviewPanel } from './PreviewPanel.tsx'
|
|
14
|
+
import { VoiceoverDialog } from './VoiceoverDialog.tsx'
|
|
15
|
+
import { ProductionCaptionTiming } from './ProductionCaptionTiming.tsx'
|
|
16
|
+
import { ProductionTimeline } from './ProductionTimeline.tsx'
|
|
17
|
+
import { ProductionMediaClips } from './ProductionMediaClips.tsx'
|
|
18
|
+
import { ProductionRecording } from './ProductionRecording.tsx'
|
|
19
|
+
import { ProductionTranscription } from './ProductionTranscription.tsx'
|
|
20
|
+
import { useProductionVoicePlayback } from '../production-voice-playback.ts'
|
|
21
|
+
|
|
22
|
+
const clockTime = (value?: number) => {
|
|
23
|
+
if (value === undefined || !Number.isFinite(value)) return '--:--.---'
|
|
24
|
+
const ms = Math.max(0, Math.round(value))
|
|
25
|
+
return `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${(ms % 60000 / 1000).toFixed(3).padStart(6, '0')}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PLAYBACK_KEYS: Record<string, PlaybackKey> = { ' ': 'play-pause', ArrowLeft: 'previous', ArrowRight: 'next' }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An explicit production workspace edits the same source; its preview has an independent compilation boundary.
|
|
32
|
+
* While it is `active`, Space plays or pauses and the left and right arrows select the previous or next step, from the
|
|
33
|
+
* workbench and from its preview alike; text entry, menus, dialogs and controls the keyboard moved to keep their keys.
|
|
34
|
+
*/
|
|
35
|
+
export function ProductionPanel({ onClose, closing, active = true }: { onClose(): void, closing: boolean, active?: boolean }) {
|
|
36
|
+
const workbench = useWorkbench(), t = useMessages(), locale = useWorkbenchState(state => state.locale), p = PRODUCTION_MESSAGES[locale]
|
|
37
|
+
const voicePlayback = useProductionVoicePlayback()
|
|
38
|
+
const serviceVersion = useWorkbenchState(state => state.voiceServiceVersion)
|
|
39
|
+
const source = useWorkbenchState(state => state.authoring), position = useWorkbenchState(state => state.position)
|
|
40
|
+
const stamp = useWorkbenchState(state => state.presentationSnapshot), view = useWorkbenchState(state => state.webView)
|
|
41
|
+
const preparing = useWorkbenchState(state => Boolean(state.isPreparingPresentation))
|
|
42
|
+
const plan = useWorkbenchState(state => state.productionPlan), playback = useWorkbenchState(state => state.productionPlayback)
|
|
43
|
+
const playbackFailure = useWorkbenchState(state => state.productionFailure), connection = useWorkbenchState(state => state.previewConnection)
|
|
44
|
+
const previewReload = useWorkbenchState(state => state.previewReload), timingDraft = useWorkbenchState(state => Boolean(state.hasDocumentTimingDrafts || state.documentTimingDraftStorageFailure))
|
|
45
|
+
const captionDraftSteps = useWorkbenchState(state => state.captionTimingDraftSteps), captionStorageFailure = useWorkbenchState(state => state.captionTimingDraftStorageFailure)
|
|
46
|
+
const frameCurrent = useWorkbenchState(state => Boolean(state.presentationSnapshot && state.previewIrRevision === state.presentationSnapshot.revision))
|
|
47
|
+
const blocked = useWorkbenchState(state => Boolean(state.authoring?.loading || state.authoring?.composing) || isAgentChanging(state))
|
|
48
|
+
const [wide, setWide] = useState(() => matchMedia('(min-width: 1100px)').matches), [tab, setTab] = useState('preview')
|
|
49
|
+
const [libraryTab, setLibraryTab] = useState('pages'), [inspectorTab, setInspectorTab] = useState('script'), [outputsOpen, setOutputsOpen] = useState(false)
|
|
50
|
+
const [generateStep, setGenerateStep] = useState<string>(), [failure, setFailure] = useState<string>()
|
|
51
|
+
const [planBusy, setPlanBusy] = useState(false), [planFailure, setPlanFailure] = useState<string>(), [planReload, setPlanReload] = useState(0)
|
|
52
|
+
const [playerBusy, setPlayerBusy] = useState(false), [seeking, setSeeking] = useState(false), controlRequest = useRef<AbortController>()
|
|
53
|
+
const [enabling, setEnabling] = useState(false), [issuesOpen, setIssuesOpen] = useState(false)
|
|
54
|
+
const [mediaRefresh, setMediaRefresh] = useState(false)
|
|
55
|
+
const [transcriptionAsset, setTranscriptionAsset] = useState<{ identity: string, id: string, revision: number }>()
|
|
56
|
+
const transcriptionSelection = useRef(0)
|
|
57
|
+
const owner = useRef({ active: true, generation: 0 }), initial = useRef(false)
|
|
58
|
+
const document = source?.content, sectionId = position && document?.sections[position.section] ? position.section : document?.sectionOrder[0]
|
|
59
|
+
const declared = Boolean(document?.metadata.plugins?.includes('@lincyaw/addon-lecture'))
|
|
60
|
+
const section = sectionId ? document?.sections[sectionId] : undefined
|
|
61
|
+
const stepId = section?.stepOrder[position && position.section === sectionId ? position.step : 0] ?? section?.stepOrder[0]
|
|
62
|
+
const sourceCurrent = Boolean(source && stamp && source.version === stamp.version && source.fingerprint === stamp.fingerprint && (source.projectRevision ?? 0) === stamp.project && !source.dirty && !source.saving && !source.error)
|
|
63
|
+
const previewVisible = sourceCurrent && view === 'present'
|
|
64
|
+
const planCurrent = Boolean(plan && source?.content?.id === plan.binding.source.documentId && source.version === plan.binding.source.version && source.fingerprint === plan.binding.source.fingerprint
|
|
65
|
+
&& !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.error)
|
|
66
|
+
const playbackConnected = Boolean(planCurrent && plan && previewVisible && frameCurrent && stamp?.revision === plan.binding.irRevision)
|
|
67
|
+
const live = plan && playback && sameProductionBinding(plan.binding, playback.binding) ? playback : undefined
|
|
68
|
+
const canPlay = Boolean(playbackConnected && plan?.canPreview && live?.status === 'ready' && !timingDraft)
|
|
69
|
+
const backgroundBody = !wide && tab === 'timeline', backgroundVisuals = !wide && tab !== 'preview'
|
|
70
|
+
useEffect(() => workbench.observeVoiceoverJobs(), [workbench, source?.identity])
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const media = matchMedia('(min-width: 1100px)'), update = () => setWide(media.matches)
|
|
73
|
+
media.addEventListener('change', update)
|
|
74
|
+
return () => media.removeEventListener('change', update)
|
|
75
|
+
}, [])
|
|
76
|
+
useEffect(() => () => { owner.current.active = false; owner.current.generation++; controlRequest.current?.abort(); workbench.closeProduction(); workbench.cancelPreviewPreparation() }, [workbench])
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
if (!declared || !source?.identity || source.loading || source.dirty || source.saving || source.composing || source.restored || source.error || blocked) { setPlanBusy(false); return }
|
|
80
|
+
setPlanBusy(true); setPlanFailure(undefined)
|
|
81
|
+
void workbench.prepareProductionPlan(controller.signal).catch(error => {
|
|
82
|
+
if (!controller.signal.aborted) setPlanFailure(error instanceof Error && error.message.startsWith('production-') ? productionFailure(error, locale) : p.planFailed)
|
|
83
|
+
}).finally(() => { if (!controller.signal.aborted) setPlanBusy(false) })
|
|
84
|
+
return () => controller.abort()
|
|
85
|
+
}, [workbench, source?.identity, source?.version, source?.fingerprint, source?.projectRevision, source?.loading, source?.dirty, source?.saving, source?.composing, source?.restored, source?.error, blocked, declared, planReload, previewReload, serviceVersion])
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const controller = new AbortController()
|
|
88
|
+
// Reading an unchanged cache plan must not cancel an in-flight load of the same immutable binding.
|
|
89
|
+
if (!plan || !playbackConnected || preparing || blocked || timingDraft) { setPlayerBusy(false); return }
|
|
90
|
+
setPlayerBusy(true)
|
|
91
|
+
void workbench.controlProduction({ binding: plan.binding, action: 'load' }, controller.signal).catch(() => {}).finally(() => { if (!controller.signal.aborted) setPlayerBusy(false) })
|
|
92
|
+
return () => controller.abort()
|
|
93
|
+
}, [workbench, plan?.binding.planId, playbackConnected, connection, preparing, blocked, timingDraft])
|
|
94
|
+
const control = async (command: ProductionPlaybackCommand) => {
|
|
95
|
+
if (!owner.current.active || !playbackConnected || blocked || closing || timingDraft) return
|
|
96
|
+
if (command.action === 'pause' || command.action === 'seek' || command.action === 'step') voicePlayback.stop()
|
|
97
|
+
controlRequest.current?.abort()
|
|
98
|
+
const controller = new AbortController(); controlRequest.current = controller
|
|
99
|
+
setSeeking(command.action === 'seek')
|
|
100
|
+
try { await workbench.controlProduction(command, controller.signal) } catch {}
|
|
101
|
+
finally { if (controlRequest.current === controller) { controlRequest.current = undefined; if (owner.current.active) setSeeking(false) } }
|
|
102
|
+
}
|
|
103
|
+
const compile = async (refreshPlan = true) => {
|
|
104
|
+
if (workbench.getState().isPreparingPresentation || !source?.identity) return
|
|
105
|
+
const identity = source.identity, generation = ++owner.current.generation
|
|
106
|
+
const current = () => owner.current.active && generation === owner.current.generation && workbench.getState().authoring?.identity === identity
|
|
107
|
+
setFailure(undefined)
|
|
108
|
+
try {
|
|
109
|
+
await workbench.flushPendingEdits()
|
|
110
|
+
if (!current()) return
|
|
111
|
+
const prepared = await workbench.preparePresentation()
|
|
112
|
+
if (!current()) return
|
|
113
|
+
if (!prepared) {
|
|
114
|
+
const message = workbench.getState().presentationFailure
|
|
115
|
+
setFailure(message === t.timingDraftPending ? undefined : message)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
workbench.setWebView('present')
|
|
119
|
+
if (refreshPlan) setPlanReload(value => value + 1)
|
|
120
|
+
} catch { if (current()) setFailure(t.presentationCompileFailed) }
|
|
121
|
+
}
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!mediaRefresh || preparing || workbench.getState().isPreparingPresentation || blocked || closing || !source?.identity
|
|
124
|
+
|| source.loading || source.dirty || source.saving || source.composing || source.restored || source.error) return
|
|
125
|
+
// Consume one merged intent; a later accepted media change can queue the next saved version.
|
|
126
|
+
setMediaRefresh(false)
|
|
127
|
+
if (!sourceCurrent || !frameCurrent) void compile()
|
|
128
|
+
}, [mediaRefresh, preparing, blocked, closing, source?.identity, source?.version, source?.fingerprint, source?.loading, source?.dirty, source?.saving, source?.composing, source?.restored, source?.error, sourceCurrent, frameCurrent])
|
|
129
|
+
useEffect(() => { if (!initial.current) { initial.current = true; void compile(false) } }, [])
|
|
130
|
+
const select = (page: string, step: string) => { voicePlayback.stop(); workbench.stopAuditions(); workbench.selectDocumentStep(page, step) }
|
|
131
|
+
const playbackKey = useRef<(key: PlaybackKey) => void>(() => {})
|
|
132
|
+
playbackKey.current = key => {
|
|
133
|
+
if (blocked || closing || !document || !sectionId || !stepId) return
|
|
134
|
+
if (key === 'play-pause') {
|
|
135
|
+
if (voicePlayback.busy || plan && canPlay && !playerBusy && !planBusy && !preparing && !seeking) void voicePlayback.toggle()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Steps run on across pages and stop at the first and the last.
|
|
139
|
+
const steps = document.sectionOrder.flatMap(page => document.sections[page]?.stepOrder.map(step => ({ page, step })) ?? [])
|
|
140
|
+
const at = steps.findIndex(item => item.page === sectionId && item.step === stepId), next = at < 0 ? undefined : steps[at + (key === 'next' ? 1 : -1)]
|
|
141
|
+
if (next) select(next.page, next.step)
|
|
142
|
+
}
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!active) return
|
|
145
|
+
watchFocusSource()
|
|
146
|
+
const press = (event: KeyboardEvent) => {
|
|
147
|
+
const key = PLAYBACK_KEYS[event.key]
|
|
148
|
+
if (!key || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || keyBelongsToTarget(event)) return
|
|
149
|
+
// Taken before the focused control sees it: Space would press the button clicked last, the arrows move in its toolbar.
|
|
150
|
+
event.preventDefault(); event.stopPropagation()
|
|
151
|
+
if (!(event.repeat && key === 'play-pause')) playbackKey.current(key)
|
|
152
|
+
}
|
|
153
|
+
window.addEventListener('keydown', press, true)
|
|
154
|
+
const stop = workbench.observePlaybackKeys(key => playbackKey.current(key))
|
|
155
|
+
return () => { window.removeEventListener('keydown', press, true); stop() }
|
|
156
|
+
}, [workbench, active])
|
|
157
|
+
const enable = async () => {
|
|
158
|
+
if (blocked || closing || enabling) return
|
|
159
|
+
setFailure(undefined); setEnabling(true)
|
|
160
|
+
try { await workbench.enableDocumentProduction(); if (owner.current.active) { workbench.cancelPreviewPreparation(); await compile() } }
|
|
161
|
+
catch (error) { if (owner.current.active) setFailure(productionFailure(error, locale)) }
|
|
162
|
+
finally { if (owner.current.active) setEnabling(false) }
|
|
163
|
+
}
|
|
164
|
+
const openOutputs = () => { voicePlayback.stop(); void workbench.pausePlayback(true).catch(() => {}); setOutputsOpen(true) }
|
|
165
|
+
if (!source?.identity || !document || !section || !sectionId || !stepId) return <PmEmpty icon="video">{t.noSections}</PmEmpty>
|
|
166
|
+
const feedback = [...new Set([
|
|
167
|
+
captionStorageFailure && p.captionStorageFailed,
|
|
168
|
+
captionDraftSteps?.length && p.captionDraft,
|
|
169
|
+
failure, planFailure,
|
|
170
|
+
planCurrent && (playbackFailure || live?.error) && productionFailure(playbackFailure ?? live?.error, locale),
|
|
171
|
+
timingDraft ? p.timingDraft : planCurrent && plan?.timing === 'estimated' ? p.estimatedPlan : planCurrent && plan?.timing === 'unknown' ? p.unknownPlan : planCurrent && !playbackConnected && !preparing ? p.playbackWaiting : undefined,
|
|
172
|
+
].filter((message): message is string => typeof message === 'string' && Boolean(message)))]
|
|
173
|
+
const diagnostics = planCurrent ? plan?.diagnostics ?? [] : []
|
|
174
|
+
const issueCount = diagnostics.length + feedback.length
|
|
175
|
+
const summary = failure ?? planFailure ?? (diagnostics[0] && ((p.diagnostics as Record<string, string>)[diagnostics[0].code] ?? p.diagnosticFallback)) ?? feedback[0]
|
|
176
|
+
const pageIndex = document.sectionOrder.indexOf(sectionId)
|
|
177
|
+
const mediaEditor = <ProductionMediaClips key={`${source.identity}:${stepId}`} identity={source.identity} version={source.version ?? ''} document={document} sectionId={sectionId} stepId={stepId}
|
|
178
|
+
binding={planCurrent ? plan?.binding : undefined} stepDurationMs={(() => { const step = planCurrent ? plan?.pages.find(page => page.sectionId === sectionId)?.steps.find(step => step.stepId === stepId) : undefined; return step?.timing === 'confirmed' && step.startMs !== undefined && step.endMs !== undefined ? step.endMs - step.startMs : undefined })()}
|
|
179
|
+
disabled={blocked || closing || !declared || !source.version || Boolean(source.dirty || source.saving || source.restored || source.error)} onChanged={() => setMediaRefresh(true)} />
|
|
180
|
+
return <section className="pmw-production" aria-label={p.title}>
|
|
181
|
+
<PmPanelHeader title={p.title} meta={wide ? document.metadata.title : undefined}
|
|
182
|
+
leading={<PmIconButton small icon="arrow-left" label={p.back} loading={closing} onClick={onClose} />}
|
|
183
|
+
actions={<>
|
|
184
|
+
<PmBadge tone={sourceCurrent && frameCurrent ? 'ok' : preparing || sourceCurrent ? 'accent' : 'warn'}>{sourceCurrent && frameCurrent ? p.current : preparing || sourceCurrent ? p.preparing : p.outdated}</PmBadge>
|
|
185
|
+
{issueCount > 0 && <PmPopover open={issuesOpen} onOpenChange={setIssuesOpen} label={p.issues(issueCount)} trigger={wide ? <PmButton small icon="warning" title={summary} aria-label={p.issues(issueCount)}>{p.issueCount(issueCount)}</PmButton> : <PmIconButton small icon="warning" label={p.issues(issueCount)} hint={summary} />}>
|
|
186
|
+
<div className="pmw-production__feedback">
|
|
187
|
+
{feedback.map(message => <PmNotice key={message} tone="warn">{message}</PmNotice>)}
|
|
188
|
+
{captionDraftSteps?.length ? document.sectionOrder.flatMap(pageId => document.sections[pageId]?.stepOrder.map((id, index) => captionDraftSteps.includes(id) ? <PmButton key={id} small onClick={() => { select(pageId, id); setIssuesOpen(false) }}>{document.sections[pageId]!.title} · {p.stepNumber(index)}</PmButton> : null) ?? []) : null}
|
|
189
|
+
{diagnostics.map((issue, index) => {
|
|
190
|
+
const page = issue.sectionId ? document.sections[issue.sectionId] : undefined, step = page && issue.stepId ? page.stepOrder.indexOf(issue.stepId) : -1
|
|
191
|
+
return <PmCard key={`${issue.code}:${issue.sectionId}:${issue.stepId}:${index}`}>
|
|
192
|
+
<p>{(p.diagnostics as Record<string, string>)[issue.code] ?? p.diagnosticFallback}</p>
|
|
193
|
+
{page && issue.stepId && step >= 0 && <PmButton small disabled={blocked || closing} onClick={() => { select(page.id, issue.stepId!); setIssuesOpen(false) }}>{page.title} · {p.stepNumber(step)}</PmButton>}
|
|
194
|
+
</PmCard>
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
</PmPopover>}
|
|
198
|
+
<PmIconButton small icon="refresh" label={p.prepare} loading={preparing} disabled={blocked || closing} onClick={() => void compile()} />
|
|
199
|
+
{wide ? <PmButton small variant="accent" icon="export" disabled={closing} onClick={openOutputs}>{p.outputs}</PmButton>
|
|
200
|
+
: <PmIconButton small icon="export" label={p.outputs} disabled={closing} onClick={openOutputs} />}
|
|
201
|
+
</>} />
|
|
202
|
+
{!declared && <PmNotice tone="accent"><span>{p.enableHint}</span><PmButton small loading={enabling} disabled={blocked || closing} onClick={() => void enable()}>{p.enable}</PmButton></PmNotice>}
|
|
203
|
+
{!wide && <PmSegmented label={p.title} value={tab} options={[{ value: 'library', label: p.library }, { value: 'preview', label: p.preview }, { value: 'properties', label: p.properties }, { value: 'timeline', label: p.timeline }]} onChange={setTab} />}
|
|
204
|
+
{voicePlayback.failure && <PmNotice tone="danger">{voicePlayback.failure}{workbench.client.openSpeechSettings && <PmButton small variant="ghost" icon="sliders" onClick={() => { voicePlayback.stop(); workbench.client.openSpeechSettings?.() }}>{p.serviceSettings}</PmButton>}</PmNotice>}
|
|
205
|
+
{voicePlayback.busy && <div role="status"><PmNotice>{voicePlayback.progress ? p.generatingProgress(voicePlayback.progress.completed, voicePlayback.progress.total) : p.generatingPlayback}</PmNotice></div>}
|
|
206
|
+
<PmSplitGroup orientation="vertical" disabled={!wide} className="pmw-production__workarea">
|
|
207
|
+
<PmSplitPanel id="production-main" defaultSize="71%" minSize={wide ? '32%' : 0} className="pmw-production__main" background={backgroundBody}>
|
|
208
|
+
<PmSplitGroup disabled={!wide} className="pmw-production__body">
|
|
209
|
+
<PmSplitPanel id="production-library" defaultSize="18%" minSize={wide ? '12%' : 0} maxSize={wide ? '30%' : '100%'} className="pmw-production__library" hidden={!wide && tab !== 'library'}>
|
|
210
|
+
<PmPanelHeader title={p.library} />
|
|
211
|
+
<PmTabs variant="dock" value={libraryTab} label={p.library} tabs={[{ value: 'pages', label: p.pages, icon: 'slides' }, { value: 'media', label: p.media, icon: 'audio' }]} onValueChange={setLibraryTab}>
|
|
212
|
+
<PmTabPanel value="pages" keepMounted>
|
|
213
|
+
<PmSearchList label={p.pages} searchLabel={p.searchPages} searchPlaceholder={p.searchPages} value={sectionId} disabled={blocked || closing}
|
|
214
|
+
items={document.sectionOrder.map((id, index) => ({ value: id, label: `${String(index + 1).padStart(2, '0')} · ${document.sections[id]!.title}`, description: p.pageSteps(document.sections[id]!.stepOrder.length), icon: 'slides' }))}
|
|
215
|
+
onValueChange={id => { const step = document.sections[id]?.stepOrder[0]; if (step) select(id, step) }} resultsLabel={p.pageResults} emptyLabel={p.noPages} loadingLabel={p.planLoading} />
|
|
216
|
+
</PmTabPanel>
|
|
217
|
+
<PmTabPanel value="media" keepMounted>{mediaEditor}</PmTabPanel>
|
|
218
|
+
</PmTabs>
|
|
219
|
+
</PmSplitPanel>
|
|
220
|
+
{wide && <PmSplitHandle label={p.resizeLibrary} />}
|
|
221
|
+
<PmSplitPanel id="production-visuals" defaultSize="56%" minSize={wide ? '30%' : 0} className="pmw-production__visuals" background={backgroundVisuals}>
|
|
222
|
+
<PmPanelHeader title={p.preview} meta={`${pageIndex + 1} · ${section.title}`} />
|
|
223
|
+
<div className="pmw-production__frame" hidden={!previewVisible}><PreviewPanel active={previewVisible} playbackKeys onPresentationAction={action => { if (action === 'refresh') void compile(); else onClose() }} /></div>
|
|
224
|
+
{!previewVisible && <PmEmpty icon="slides" title={p.unavailable} action={<PmButton small loading={preparing} disabled={blocked || closing} onClick={() => void compile()}>{p.prepare}</PmButton>}>{p.previewHint}</PmEmpty>}
|
|
225
|
+
<div className="pmw-production__monitor-tools">
|
|
226
|
+
<PmToolbar scroll label={p.step}>{section.stepOrder.map((id, index) => <PmToolbarButton asChild key={id}><PmButton small aria-label={p.stepNumber(index)} aria-pressed={stepId === id} disabled={blocked || closing} onClick={() => select(sectionId, id)}>{index + 1}</PmButton></PmToolbarButton>)}</PmToolbar>
|
|
227
|
+
<PmToolbar label={p.timeline} className="pmw-production__transport">
|
|
228
|
+
<output aria-label={p.timelineLabels.position}>{clockTime(live?.position?.timeMs)} / {clockTime(planCurrent ? plan?.durationMs : undefined)}</output>
|
|
229
|
+
<PmToolbarButton asChild><PmIconButton small icon={voicePlayback.busy || live?.position?.playing ? 'pause' : 'play'} label={voicePlayback.busy || live?.position?.playing ? p.pause : p.play} hint={p.playOnDemand} shortcut={SHORTCUTS.playPause} aria-keyshortcuts="Space" loading={playerBusy && !voicePlayback.busy} disabled={!voicePlayback.busy && (!canPlay || planBusy || preparing || blocked || closing || seeking)} onClick={() => void voicePlayback.toggle()} /></PmToolbarButton>
|
|
230
|
+
<PmSelect label={p.rate} labelHidden value={live?.position?.rate === undefined ? '' : String(live.position.rate)} disabled={!canPlay || voicePlayback.busy || playerBusy || planBusy || preparing || blocked || closing || seeking} onChange={event => { if (plan) void control({ binding: plan.binding, action: 'rate', rate: Number(event.target.value) }) }}>
|
|
231
|
+
{live?.position?.rate === undefined && <option value="">{p.timelineLabels.unknown}</option>}{PRODUCTION_PLAYBACK_RATES.map(value => <option key={value} value={value}>{value}×</option>)}
|
|
232
|
+
</PmSelect>
|
|
233
|
+
{(planFailure || playbackFailure) && <PmToolbarButton asChild><PmIconButton small icon="refresh" label={p.planRetry} onClick={() => setPlanReload(value => value + 1)} /></PmToolbarButton>}
|
|
234
|
+
</PmToolbar>
|
|
235
|
+
</div>
|
|
236
|
+
</PmSplitPanel>
|
|
237
|
+
{wide && <PmSplitHandle label={p.resizeProperties} />}
|
|
238
|
+
<PmSplitPanel id="production-properties" defaultSize="26%" minSize={wide ? '20%' : 0} maxSize={wide ? '42%' : '100%'} className="pmw-production__properties" hidden={!wide && tab !== 'properties'}>
|
|
239
|
+
<PmPanelHeader title={p.properties} meta={p.stepNumber(section.stepOrder.indexOf(stepId))} />
|
|
240
|
+
<PmTabs variant="dock" value={inspectorTab} label={p.properties} tabs={[{ value: 'script', label: p.narration }, { value: 'voice', label: p.voiceTab }, { value: 'captions', label: p.captionsTab }]} onValueChange={setInspectorTab}>
|
|
241
|
+
<PmTabPanel value="script" keepMounted><div className="pmw-production__fields pm-ui-block-editor">
|
|
242
|
+
<DocumentStepNarration sectionId={sectionId} stepId={stepId} expanded advanced panelId="production-step-fields" onPause={() => select(sectionId, stepId)} />
|
|
243
|
+
</div></PmTabPanel>
|
|
244
|
+
<PmTabPanel value="voice" keepMounted><div className="pmw-production__fields">
|
|
245
|
+
<DocumentVoiceField key={`${source.identity}:${stepId}`} stepId={stepId} disabled={blocked || closing} onGenerate={() => { voicePlayback.stop(); workbench.stopAuditions(); setGenerateStep(stepId) }} />
|
|
246
|
+
<ProductionRecording key={source.identity} identity={source.identity} sectionId={sectionId} stepId={stepId} active={!outputsOpen && (wide || tab === 'properties') && inspectorTab === 'voice'} disabled={blocked || closing}
|
|
247
|
+
onImportStarted={() => transcriptionSelection.current}
|
|
248
|
+
onImported={(assetId, selection) => {
|
|
249
|
+
setMediaRefresh(true)
|
|
250
|
+
if (selection === transcriptionSelection.current) setTranscriptionAsset({ identity: source.identity!, id: assetId, revision: ++transcriptionSelection.current })
|
|
251
|
+
}} onAdopted={() => setMediaRefresh(true)} />
|
|
252
|
+
<ProductionTranscription key={source.identity} identity={source.identity} directory={source.directory} document={document} sectionId={sectionId} stepId={stepId}
|
|
253
|
+
active={!outputsOpen && (wide || tab === 'properties') && inspectorTab === 'voice'} disabled={blocked || closing}
|
|
254
|
+
selectedAsset={transcriptionAsset?.identity === source.identity ? transcriptionAsset : undefined}
|
|
255
|
+
onSelectAsset={() => { transcriptionSelection.current++; setTranscriptionAsset(undefined) }} />
|
|
256
|
+
</div></PmTabPanel>
|
|
257
|
+
<PmTabPanel value="captions" keepMounted><ProductionCaptionTiming key={source.identity} identity={source.identity} stepId={stepId} plan={plan} current={planCurrent} loading={preparing || planBusy} disabled={blocked || closing} onChanged={() => setMediaRefresh(true)} /></PmTabPanel>
|
|
258
|
+
</PmTabs>
|
|
259
|
+
</PmSplitPanel>
|
|
260
|
+
</PmSplitGroup>
|
|
261
|
+
</PmSplitPanel>
|
|
262
|
+
{wide && <PmSplitHandle label={p.resizeTimeline} />}
|
|
263
|
+
<PmSplitPanel id="production-timeline" defaultSize="29%" minSize={wide ? '18%' : 0} maxSize={wide ? '65%' : '100%'} scroll className="pmw-production__timeline" hidden={!wide && tab !== 'timeline'}>
|
|
264
|
+
<ProductionTimeline plan={plan} sectionId={sectionId} stepId={stepId} active={wide || tab === 'timeline'} current={planCurrent} seekEnabled={canPlay && plan?.timing === 'confirmed' && !voicePlayback.busy && !playerBusy && !preparing && !planBusy}
|
|
265
|
+
loading={planBusy && !planCurrent} seeking={seeking} disabled={blocked || closing} onSelectStep={id => { const step = document.steps[id]; if (step) select(step.sectionId, id) }}
|
|
266
|
+
onSeek={timeMs => { if (plan) void control({ binding: plan.binding, action: 'seek', timeMs }) }} />
|
|
267
|
+
</PmSplitPanel>
|
|
268
|
+
</PmSplitGroup>
|
|
269
|
+
<PmDialog open={outputsOpen} label={p.outputs} closeLabel={t.close} onClose={() => setOutputsOpen(false)}>{outputsOpen && <ExportPanel embedded onClose={() => setOutputsOpen(false)} />}</PmDialog>
|
|
270
|
+
{generateStep && <VoiceoverDialog key={`${source.identity}:${generateStep}`} stepId={generateStep} onClose={() => setGenerateStep(undefined)} />}
|
|
271
|
+
</section>
|
|
272
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { useRecordingAdoption } from '../use-recording-adoption.ts'
|
|
3
|
+
import { PRODUCTION_MESSAGES } from '../production-messages.ts'
|
|
4
|
+
import { Audition } from '../audition.ts'
|
|
5
|
+
import type { AuditionState } from '../audition.ts'
|
|
6
|
+
import { documentRecording, recordingUnavailable } from '../audio-recording.ts'
|
|
7
|
+
import { AUDIO_RECORDING_MESSAGES } from '../audio-recording-messages.ts'
|
|
8
|
+
import { PmButton, PmCard, PmDialog, PmNotice, PmSelect } from '../components.tsx'
|
|
9
|
+
import { useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
10
|
+
import { mediaFailure } from '../media-messages.ts'
|
|
11
|
+
import { ProductionRecordingRange } from './ProductionRecordingRange.tsx'
|
|
12
|
+
|
|
13
|
+
interface Props { identity: string, sectionId: string, stepId: string, active: boolean, disabled: boolean, onImportStarted(): number, onImported(assetId: string, selection: number): void, onAdopted(): void }
|
|
14
|
+
const elapsed = (ms: number) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
|
|
15
|
+
|
|
16
|
+
/** Video-only microphone material. The document actor and existing binary transaction still own every save. */
|
|
17
|
+
export function ProductionRecording({ identity, sectionId, stepId, active, disabled, onImportStarted, onImported, onAdopted }: Props) {
|
|
18
|
+
const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = AUDIO_RECORDING_MESSAGES[locale]
|
|
19
|
+
const source = useWorkbenchState(state => state.authoring)
|
|
20
|
+
const [recording] = useState(() => documentRecording(workbench, identity))
|
|
21
|
+
const state = useSyncExternalStore(recording.subscribe, recording.snapshot)
|
|
22
|
+
const audition = useRef<Audition>(), request = useRef<AbortController>(), mounted = useRef(false)
|
|
23
|
+
const [listening, setListening] = useState<AuditionState>({ status: 'idle' }), [saving, setSaving] = useState(false), [failure, setFailure] = useState<string>(), [discard, setDiscard] = useState(false)
|
|
24
|
+
const [unavailable] = useState(recordingUnavailable)
|
|
25
|
+
const current = () => mounted.current && workbench.getState().authoring?.identity === identity
|
|
26
|
+
const candidate = state.candidate
|
|
27
|
+
const adoption = useRecordingAdoption({ identity, sectionId, stepId, active, disabled, candidateAssetId: candidate?.assetId, onAdopted })
|
|
28
|
+
const capturing = ['requesting', 'recording', 'stopping'].includes(state.phase)
|
|
29
|
+
const status = state.phase === 'requesting' ? t.requesting : state.phase === 'recording' ? t.recording : state.phase === 'stopping' ? t.stopping : candidate ? candidate.assetId ? t.imported : t.ready : undefined
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
mounted.current = true
|
|
32
|
+
return () => { mounted.current = false; request.current?.abort(); audition.current?.dispose(); audition.current = undefined; recording.stop() }
|
|
33
|
+
}, [recording])
|
|
34
|
+
useEffect(() => () => { recording.stop(); audition.current?.stop() }, [recording, sectionId, stepId, active])
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!active) { recording.stop(); audition.current?.stop() }
|
|
37
|
+
if (candidate?.assetId && source?.identity === identity && source.content && !source.content.assets[candidate.assetId]) recording.forgetImport(candidate.assetId)
|
|
38
|
+
}, [active, candidate?.assetId, source?.content, source?.identity, identity, recording])
|
|
39
|
+
const start = () => {
|
|
40
|
+
setFailure(undefined)
|
|
41
|
+
void recording.start(async () => {
|
|
42
|
+
workbench.stopAuditions()
|
|
43
|
+
await workbench.pausePlayback(true)
|
|
44
|
+
if (!current() || !active) throw new Error('recording-failed')
|
|
45
|
+
return workbench.registerAudioCapture(() => recording.stop())
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
const listen = () => {
|
|
49
|
+
if (!candidate) return
|
|
50
|
+
if (!audition.current) {
|
|
51
|
+
const player = audition.current = new Audition(workbench)
|
|
52
|
+
player.subscribe(() => { if (current()) setListening(player.snapshot()) })
|
|
53
|
+
}
|
|
54
|
+
if (listening.status === 'playing' || listening.status === 'loading') audition.current.stop()
|
|
55
|
+
else void audition.current.play(async () => ({ audio: candidate.file }))
|
|
56
|
+
}
|
|
57
|
+
const save = async () => {
|
|
58
|
+
if (!candidate || saving || !current()) return
|
|
59
|
+
const file = candidate.file, selection = adoption.revision(), transcriptionSelection = onImportStarted(), controller = new AbortController()
|
|
60
|
+
request.current = controller; setSaving(true); setFailure(undefined)
|
|
61
|
+
const owns = () => current() && !controller.signal.aborted && request.current === controller && recording.snapshot().candidate?.file === file
|
|
62
|
+
try {
|
|
63
|
+
await workbench.flushPendingEdits(controller.signal)
|
|
64
|
+
if (!owns()) return
|
|
65
|
+
const currentSource = workbench.getState().authoring
|
|
66
|
+
if (!currentSource?.version || currentSource.identity !== identity || currentSource.dirty || currentSource.saving || currentSource.composing || currentSource.restored || currentSource.error) throw new Error('production-source-changed')
|
|
67
|
+
const answer = await workbench.ensureDocumentAudio(file, { identity, version: currentSource.version }, controller.signal)
|
|
68
|
+
if (!owns()) return
|
|
69
|
+
recording.imported(file, answer.inspection.assetId); adoption.imported(answer.inspection.assetId, selection); onImported(answer.inspection.assetId, transcriptionSelection)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (owns()) setFailure(`${t.saveFailed} ${mediaFailure(error, locale)}`)
|
|
72
|
+
} finally {
|
|
73
|
+
if (request.current === controller) { request.current = undefined; if (current()) setSaving(false) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return <PmCard className="pmw-audio-recording" data-recording={state.phase}>
|
|
77
|
+
<strong>{t.title}</strong>
|
|
78
|
+
{status && !(status === t.imported && adoption.adopted && candidate?.assetId === adoption.selected) && <span className="pm-ui-hint"><span role="status">{saving ? t.saving : status}</span>{state.phase === 'recording' && <span aria-hidden="true"> · {elapsed(state.elapsedMs)}</span>}</span>}
|
|
79
|
+
{(state.error || unavailable) && <PmNotice tone="warn">{t.failures[state.error ?? unavailable!] ?? t.failures['recording-failed']}</PmNotice>}
|
|
80
|
+
{failure && <PmNotice tone="danger">{failure}</PmNotice>}
|
|
81
|
+
{listening.status === 'failed' && <PmNotice tone="warn">{t.failures[listening.error ?? 'audition-request'] ?? t.failures['audition-request']}</PmNotice>}
|
|
82
|
+
<div className="pmw-voice__actions">
|
|
83
|
+
{!capturing && !candidate && <PmButton small variant="primary" disabled={disabled || saving || !active || Boolean(unavailable)} onClick={start}>{t.start}</PmButton>}
|
|
84
|
+
{state.phase === 'requesting' && <PmButton small onClick={() => recording.cancelTake()}>{t.cancel}</PmButton>}
|
|
85
|
+
{state.phase === 'recording' && <><PmButton small onClick={() => recording.stop()}>{t.stop}</PmButton><PmButton small onClick={() => recording.cancelTake()}>{t.cancelTake}</PmButton></>}
|
|
86
|
+
{candidate && !capturing && <PmButton small disabled={!active || saving} loading={listening.status === 'loading'} onClick={listen}>{listening.status === 'playing' ? t.stopAudition : t.audition}</PmButton>}
|
|
87
|
+
{candidate && !candidate.assetId && !capturing && <PmButton small variant="primary" disabled={disabled || !active} loading={saving} onClick={() => void save()}>{failure ? t.retry : t.save}</PmButton>}
|
|
88
|
+
{candidate && !capturing && <PmButton small disabled={disabled || saving || !active || Boolean(unavailable)} onClick={start}>{t.rerecord}</PmButton>}
|
|
89
|
+
{saving && <PmButton small onClick={() => { request.current?.abort(); setSaving(false) }}>{t.cancelSave}</PmButton>}
|
|
90
|
+
{candidate && !capturing && !saving && <PmButton small onClick={() => setDiscard(true)}>{t.discard}</PmButton>}
|
|
91
|
+
</div>
|
|
92
|
+
{(adoption.assets.length > 0 || adoption.selected) && <>
|
|
93
|
+
<PmSelect label={t.source} value={adoption.selected} disabled={disabled || !active || saving} onChange={event => adoption.choose(event.target.value)}>
|
|
94
|
+
<option value="">{t.choose}</option>{adoption.selected && !adoption.assets.some(asset => asset.id === adoption.selected) && <option value={adoption.selected} disabled>{t.removedAudio}</option>}{adoption.assets.map(asset => <option key={asset.id} value={asset.id}>{asset.title || asset.location.split('/').at(-1) || asset.id}</option>)}
|
|
95
|
+
</PmSelect>
|
|
96
|
+
<div className="pmw-voice__actions">
|
|
97
|
+
<PmButton small variant="primary" disabled={disabled || !active || saving || capturing || !adoption.assets.some(asset => asset.id === adoption.selected) || adoption.adopted} loading={adoption.busy} onClick={() => void adoption.adopt()}>{adoption.adopted ? t.adopted : adoption.failure ? t.retryAdoption : t.adopt}</PmButton>
|
|
98
|
+
{adoption.busy && <PmButton small onClick={adoption.cancel}>{t.cancelAdoption}</PmButton>}
|
|
99
|
+
</div>
|
|
100
|
+
{adoption.failure && <PmNotice tone="danger">{['production-source-changed', 'document-version-changed'].includes(adoption.failure) ? t.adoptionSourceChanged
|
|
101
|
+
: adoption.failure === 'recording-cue-unsupported' || adoption.failure === 'recording-start-conflict' ? (PRODUCTION_MESSAGES[locale].diagnostics as Record<string, string>)[`production.${adoption.failure}`]
|
|
102
|
+
: adoption.failure.startsWith('production.asset-') || adoption.failure === 'production-audio-unavailable' ? mediaFailure(adoption.failure, locale) : t.adoptionFailed}</PmNotice>}
|
|
103
|
+
</>}
|
|
104
|
+
<ProductionRecordingRange identity={identity} sectionId={sectionId} stepId={stepId} active={active} disabled={disabled || capturing || saving} onChanged={onAdopted} />
|
|
105
|
+
{discard && <PmDialog open label={t.discardTitle} closeLabel={t.cancel} description={t.discardHint} onClose={() => setDiscard(false)} actions={<><PmButton onClick={() => setDiscard(false)}>{t.keep}</PmButton><PmButton variant="danger" onClick={() => { audition.current?.stop(); recording.discard(); setFailure(undefined); setDiscard(false) }}>{t.discard}</PmButton></>} />}
|
|
106
|
+
</PmCard>
|
|
107
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { recordingSourceRange } from '@lincyaw/kernel'
|
|
3
|
+
import type { DocumentRecordingAdoption, ProductionAssetInspection, TimeRange, VoiceSource } from '@lincyaw/kernel'
|
|
4
|
+
import { PmButton, PmInput, PmNotice } from '../components.tsx'
|
|
5
|
+
import { useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
6
|
+
import { AUDIO_RECORDING_MESSAGES } from '../audio-recording-messages.ts'
|
|
7
|
+
import { MEDIA_MESSAGES, mediaFailure } from '../media-messages.ts'
|
|
8
|
+
import { mediaTime, mediaTimeValue } from '../media-time.ts'
|
|
9
|
+
import { PRODUCTION_MESSAGES } from '../production-messages.ts'
|
|
10
|
+
|
|
11
|
+
type RecordingSource = Extract<VoiceSource, { type: 'recording' }>
|
|
12
|
+
interface RangeDraft { version: string, voice: RecordingSource, range?: TimeRange, startText?: string, endText?: string }
|
|
13
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
14
|
+
|
|
15
|
+
/** Video-only pending input; the existing recording transaction and source actor own every write. */
|
|
16
|
+
export function ProductionRecordingRange({ identity, sectionId, stepId, active, disabled, onChanged }: {
|
|
17
|
+
identity: string, sectionId: string, stepId: string, active: boolean, disabled: boolean, onChanged(): void,
|
|
18
|
+
}) {
|
|
19
|
+
const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), source = useWorkbenchState(state => state.authoring)
|
|
20
|
+
const t = MEDIA_MESSAGES[locale], r = AUDIO_RECORDING_MESSAGES[locale]
|
|
21
|
+
const document = source?.identity === identity ? source.content : undefined
|
|
22
|
+
const effective = document?.production.stepOverrides[stepId]?.voiceSource ?? document?.production.sectionOverrides?.[sectionId]?.voiceSource ?? document?.production.defaults.voiceSource
|
|
23
|
+
const voice = effective?.type === 'recording' ? effective : undefined, key = JSON.stringify([stepId, voice?.assetId])
|
|
24
|
+
const drafts = useRef(new Map<string, RangeDraft>()), mounted = useRef(true), request = useRef<AbortController>()
|
|
25
|
+
const [inspection, setInspection] = useState<{ version: string, value: ProductionAssetInspection }>(), [loading, setLoading] = useState(false)
|
|
26
|
+
const [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>(), [reload, setReload] = useState(0), [, render] = useState(0)
|
|
27
|
+
const rangeErrorId = useId()
|
|
28
|
+
const failureText = (error: unknown) => {
|
|
29
|
+
const code = error instanceof Error ? error.message : String(error)
|
|
30
|
+
return code === 'recording-cue-unsupported' || code === 'recording-start-conflict' ? (PRODUCTION_MESSAGES[locale].diagnostics as Record<string, string>)[`production.${code}`]!
|
|
31
|
+
: code === 'recording-source-changed' || code === 'production-source-changed' || code === 'document-version-changed' ? r.adoptionSourceChanged : mediaFailure(error, locale)
|
|
32
|
+
}
|
|
33
|
+
const sent = useRef<{ key: string, draft: RangeDraft, request: DocumentRecordingAdoption }>(), reported = useRef<string>()
|
|
34
|
+
const latest = useRef({ key, active, disabled, onChanged }); latest.current = { key, active, disabled, onChanged }
|
|
35
|
+
const current = () => mounted.current && workbench.getState().authoring?.identity === identity
|
|
36
|
+
const inspected = inspection && inspection.version === source?.version && inspection.value.assetId === voice?.assetId ? inspection.value : undefined
|
|
37
|
+
const draft = drafts.current.get(key) ?? (voice ? { version: source?.version ?? '', voice } : undefined)
|
|
38
|
+
const range = draft?.range ?? (draft && inspected ? recordingSourceRange(draft.voice, inspected.durationMs) : undefined)
|
|
39
|
+
const start = draft ? mediaTimeValue(draft.startText, range?.startMs ?? draft.voice.sourceRange?.startMs ?? 0) : undefined
|
|
40
|
+
const end = draft ? mediaTimeValue(draft.endText, range?.endMs ?? draft.voice.sourceRange?.endMs ?? 0) : undefined
|
|
41
|
+
const valid = Boolean(inspected && start !== undefined && end !== undefined && start >= 0 && end > start && end <= inspected.durationMs)
|
|
42
|
+
const canonicalRange = voice && inspected ? recordingSourceRange(voice, inspected.durationMs) : undefined
|
|
43
|
+
const changed = Boolean(valid && !same(canonicalRange, { startMs: start, endMs: end }))
|
|
44
|
+
const composing = useRef(false)
|
|
45
|
+
const clean = () => { const value = workbench.getState().authoring; return value?.identity === identity && !value.loading && !value.dirty && !value.saving && !value.composing && !value.restored && !value.recovering && !value.error ? value : undefined }
|
|
46
|
+
const settle = () => {
|
|
47
|
+
const value = sent.current, actual = clean(), step = value && actual?.content?.steps[value.request.stepId]
|
|
48
|
+
const saved = step && (actual!.content!.production.stepOverrides[step.id]?.voiceSource ?? actual!.content!.production.sectionOverrides?.[step.sectionId]?.voiceSource ?? actual!.content!.production.defaults.voiceSource)
|
|
49
|
+
if (!value || !actual?.version || saved?.type !== 'recording' || saved.assetId !== value.request.assetId || !same(saved.sourceRange, value.request.sourceRange)
|
|
50
|
+
|| actual.content?.assets[saved.assetId]?.fingerprint !== `sha256:${value.request.sha256}`) return false
|
|
51
|
+
const pending = drafts.current.get(value.key)
|
|
52
|
+
if (pending === value.draft) drafts.current.delete(value.key)
|
|
53
|
+
else if (pending?.version === value.draft.version && same(pending.voice, value.draft.voice)) drafts.current.set(value.key, { ...pending, version: actual.version, voice: structuredClone(saved) })
|
|
54
|
+
if (current()) { setFailure(undefined); render(value => value + 1) }
|
|
55
|
+
if (latest.current.key === value.key && latest.current.active && !latest.current.disabled && reported.current !== value.request.id) {
|
|
56
|
+
reported.current = value.request.id; latest.current.onChanged()
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
useEffect(() => () => { mounted.current = false; request.current?.abort() }, [])
|
|
61
|
+
useEffect(() => { request.current?.abort(); request.current = undefined; setBusy(false); setFailure(undefined) }, [key, active])
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const version = source?.version, assetId = voice?.assetId, controller = new AbortController()
|
|
64
|
+
if (!active || !version || !assetId || disabled) { setLoading(false); return }
|
|
65
|
+
setLoading(true)
|
|
66
|
+
void workbench.inspectDocumentAudio(assetId, { identity, version }, controller.signal).then(answer => {
|
|
67
|
+
if (!controller.signal.aborted && current() && latest.current.key === key) setInspection({ version: answer.version, value: answer.inspection })
|
|
68
|
+
}, error => { if (!controller.signal.aborted && current() && latest.current.key === key) setFailure(failureText(error)) })
|
|
69
|
+
.finally(() => { if (!controller.signal.aborted && current() && latest.current.key === key) setLoading(false) })
|
|
70
|
+
return () => controller.abort()
|
|
71
|
+
}, [workbench, identity, source?.version, voice?.assetId, key, active, disabled, reload])
|
|
72
|
+
useEffect(() => { settle() }, [source?.version, source?.content, source?.loading, source?.dirty, source?.saving, source?.composing, source?.restored, source?.error, active, disabled, key])
|
|
73
|
+
const edit = (patch: Partial<RangeDraft>) => {
|
|
74
|
+
if (!draft) return
|
|
75
|
+
drafts.current.set(key, { ...draft, ...range ? { range: { ...range } } : {}, ...patch }); setFailure(undefined); render(value => value + 1)
|
|
76
|
+
}
|
|
77
|
+
const apply = async () => {
|
|
78
|
+
if (!voice || !draft || !inspected || !valid || !changed || busy || disabled || !active || composing.current || !current()) return
|
|
79
|
+
const source = clean()
|
|
80
|
+
if (!source?.version || !source.fingerprint || source.version !== draft.version || !source.content) return
|
|
81
|
+
const controller = new AbortController(), proposed = { startMs: start!, endMs: end! }
|
|
82
|
+
const previous = sent.current?.request
|
|
83
|
+
const change: DocumentRecordingAdoption = previous && previous.source.version === source.version && previous.stepId === stepId && previous.assetId === voice.assetId && previous.sha256 === inspected.sha256 && same(previous.sourceRange, proposed)
|
|
84
|
+
? previous : { id: crypto.randomUUID(), source: { documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, stepId, assetId: voice.assetId, sha256: inspected.sha256, sourceRange: proposed }
|
|
85
|
+
const stored = drafts.current.get(key) ?? draft
|
|
86
|
+
drafts.current.set(key, stored); sent.current = { key, draft: stored, request: change }; request.current = controller
|
|
87
|
+
setBusy(true); setFailure(undefined)
|
|
88
|
+
try { await workbench.adoptDocumentRecording(change, { identity, version: source.version }, controller.signal); if (current()) settle() }
|
|
89
|
+
catch (error) { if (current() && request.current === controller && !settle() && !controller.signal.aborted) setFailure(failureText(error)) }
|
|
90
|
+
finally { if (request.current === controller) { request.current = undefined; if (current()) setBusy(false) } }
|
|
91
|
+
}
|
|
92
|
+
if (!voice || !draft) return null
|
|
93
|
+
const review = draft.version !== source?.version
|
|
94
|
+
const rangeError = start === undefined || end === undefined ? t.invalidTime : inspected && !valid ? t.invalidRange : undefined
|
|
95
|
+
const trimmed = Boolean(canonicalRange && inspected && (canonicalRange.startMs > 0 || canonicalRange.endMs < inspected.durationMs))
|
|
96
|
+
const hasCaptions = document?.steps[stepId]?.narrationIds.some(id => document.narrations[id]?.content.some(node => node.type !== 'cue' && (node.type === 'text' ? node.text : node.display).trim()))
|
|
97
|
+
return <section className="pm-ui-media-clip-editor" aria-label={r.trim} onFocusCapture={() => { if (!disabled) void workbench.pausePlayback() }}
|
|
98
|
+
onCompositionStart={() => { composing.current = true }} onCompositionEnd={() => { composing.current = false }}
|
|
99
|
+
onKeyDown={event => { event.stopPropagation(); if (event.key === 'Enter' && event.target instanceof HTMLInputElement && !event.nativeEvent.isComposing && !composing.current) { event.preventDefault(); void apply() } }}>
|
|
100
|
+
<strong>{r.trim}</strong>
|
|
101
|
+
<span>{document?.assets[voice.assetId]?.title || r.title}</span>
|
|
102
|
+
{loading ? <span role="status">{t.loading}</span> : inspected && <output className="pm-ui-field__hint">{t.original(mediaTime(inspected.durationMs), valid ? mediaTime(end! - start!) : undefined)}</output>}
|
|
103
|
+
<div className="pm-ui-media-clip-editor__times">
|
|
104
|
+
<PmInput label={t.in} density="compact" width="time" maxLength={32} value={draft.startText ?? mediaTime(range?.startMs ?? draft.voice.sourceRange?.startMs ?? 0)} aria-describedby={rangeError ? rangeErrorId : undefined} aria-invalid={Boolean(rangeError) || undefined} readOnly={disabled || !inspected} onChange={event => edit({ startText: event.target.value })} />
|
|
105
|
+
<PmInput label={t.out} density="compact" width="time" maxLength={32} value={draft.endText ?? mediaTime(range?.endMs ?? draft.voice.sourceRange?.endMs ?? 0)} aria-describedby={rangeError ? rangeErrorId : undefined} aria-invalid={Boolean(rangeError) || undefined} readOnly={disabled || !inspected} onChange={event => edit({ endText: event.target.value })} />
|
|
106
|
+
</div>
|
|
107
|
+
{rangeError && <PmNotice tone="warn"><span id={rangeErrorId}>{rangeError}</span></PmNotice>}
|
|
108
|
+
<div className="pm-ui-media-clip-editor__actions">
|
|
109
|
+
<PmButton small variant="primary" loading={busy} disabled={disabled || !active || !valid || !changed || review} onClick={() => void apply()}>{t.apply}</PmButton>
|
|
110
|
+
<PmButton small disabled={disabled || !inspected} onClick={() => edit({ range: { startMs: 0, endMs: inspected!.durationMs }, startText: undefined, endText: undefined })}>{t.restore}</PmButton>
|
|
111
|
+
{busy && <PmButton small onClick={() => { request.current?.abort(); request.current = undefined; setBusy(false) }}>{AUDIO_RECORDING_MESSAGES[locale].cancelAdoption}</PmButton>}
|
|
112
|
+
</div>
|
|
113
|
+
{review && <PmNotice tone="warn">{t.review}<PmButton small disabled={disabled || !inspected || busy} onClick={() => edit({ version: source!.version!, voice: structuredClone(voice) })}>{t.check}</PmButton></PmNotice>}
|
|
114
|
+
{trimmed && hasCaptions && <PmNotice tone="warn">{r.captionReview}</PmNotice>}
|
|
115
|
+
{failure && <PmNotice tone="danger">{failure}<PmButton small disabled={disabled || busy} onClick={() => { setInspection(undefined); setFailure(undefined); setReload(value => value + 1) }}>{t.retry}</PmButton></PmNotice>}
|
|
116
|
+
{drafts.current.has(key) && <span className="pm-ui-field__hint" role="status">{t.pending}</span>}
|
|
117
|
+
</section>
|
|
118
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ProductionPlan } from '@lincyaw/kernel'
|
|
3
|
+
import { sameProductionBinding } from '@lincyaw/kernel'
|
|
4
|
+
import { PmTimeline } from '../components.tsx'
|
|
5
|
+
import type { TimelineTrack } from '../components.tsx'
|
|
6
|
+
import { AudioWaveforms } from '../audio-waveform.ts'
|
|
7
|
+
import type { AudioWaveform } from '../audio-waveform.ts'
|
|
8
|
+
import { useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
9
|
+
import { PRODUCTION_MESSAGES } from '../production-messages.ts'
|
|
10
|
+
|
|
11
|
+
/** Projects the server's one plan into this page's relative ruler; it never computes a second schedule. */
|
|
12
|
+
export function ProductionTimeline({ plan, sectionId, stepId, active, current, seekEnabled, loading, seeking, disabled, onSeek, onSelectStep }: {
|
|
13
|
+
plan?: ProductionPlan, sectionId: string, stepId: string, active: boolean, current: boolean, seekEnabled: boolean,
|
|
14
|
+
loading: boolean, seeking: boolean, disabled: boolean, onSeek(timeMs: number): void, onSelectStep(stepId: string): void,
|
|
15
|
+
}) {
|
|
16
|
+
const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), p = PRODUCTION_MESSAGES[locale]
|
|
17
|
+
const source = useWorkbenchState(state => state.authoring), playback = useWorkbenchState(state => state.productionPlayback)
|
|
18
|
+
const directory = useWorkbenchState(state => state.document)
|
|
19
|
+
const reader = useRef<AudioWaveforms>(), [waveState, setWaves] = useState<{ planId?: string, values: Record<string, AudioWaveform | false> }>({ values: {} })
|
|
20
|
+
const waves = waveState.planId === plan?.binding.planId ? waveState.values : {}
|
|
21
|
+
const [zoom, setZoom] = useState(1)
|
|
22
|
+
useEffect(() => { reader.current = new AudioWaveforms(); return () => { reader.current?.dispose(); reader.current = undefined } }, [])
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setWaves({ planId: plan?.binding.planId, values: {} })
|
|
25
|
+
if (!active || !current || !plan || directory === undefined) return
|
|
26
|
+
const controller = new AbortController(), decoder = reader.current
|
|
27
|
+
if (!decoder) return
|
|
28
|
+
const audio = new Map(plan.tracks.flatMap(track => track.kind === 'voice' ? track.segments.filter(segment => segment.sectionId === sectionId && segment.status === 'cached' && segment.audio).map(segment => [segment.audio!.clipId, segment.audio!] as const) : []))
|
|
29
|
+
for (const clip of audio.values()) {
|
|
30
|
+
void decoder.read(JSON.stringify([plan.binding.planId, clip.clipId, clip.sha256]), signal => workbench.client.readProductionMedia(directory, { ...plan.binding, clipId: clip.clipId }, signal), clip.durationMs, controller.signal).then(
|
|
31
|
+
value => { if (!controller.signal.aborted) setWaves(previous => previous.planId === plan.binding.planId ? { ...previous, values: { ...previous.values, [clip.clipId]: value } } : previous) },
|
|
32
|
+
() => { if (!controller.signal.aborted) setWaves(previous => previous.planId === plan.binding.planId ? { ...previous, values: { ...previous.values, [clip.clipId]: false } } : previous) },
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return () => controller.abort()
|
|
36
|
+
}, [workbench, directory, plan, sectionId, active, current])
|
|
37
|
+
const document = source?.content, section = document?.sections[sectionId]
|
|
38
|
+
const page = current ? plan?.pages.find(page => page.sectionId === sectionId) : undefined
|
|
39
|
+
const start = page?.startMs, end = page?.endMs, duration = start !== undefined && end !== undefined ? Math.max(0, end - start) : undefined
|
|
40
|
+
const position = plan && current && playback && sameProductionBinding(playback.binding, plan.binding) && playback.position?.sectionId === sectionId && start !== undefined ? Math.max(0, playback.position.timeMs - start) : undefined
|
|
41
|
+
const tracks: TimelineTrack[] = page && plan ? plan.tracks.map(track => ({ id: track.kind, label: p.tracks[track.kind], segments: track.segments.filter(segment => segment.sectionId === sectionId).map(segment => {
|
|
42
|
+
const step = document?.steps[segment.stepId], index = section?.stepOrder.indexOf(segment.stepId) ?? -1
|
|
43
|
+
const wave = track.kind === 'voice' && segment.audio ? waves[segment.audio.clipId] : undefined
|
|
44
|
+
const status = segment.status ? p.segmentStatus[segment.status] : undefined
|
|
45
|
+
return { id: segment.id, stepId: segment.stepId, timing: segment.timing,
|
|
46
|
+
label: track.kind === 'visual' ? step?.title ?? p.stepNumber(Math.max(0, index)) : segment.label,
|
|
47
|
+
...segment.startMs !== undefined && start !== undefined ? { startMs: Math.max(0, segment.startMs - start) } : {},
|
|
48
|
+
...segment.endMs !== undefined && start !== undefined ? { endMs: Math.max(0, segment.endMs - start) } : {},
|
|
49
|
+
statusLabel: [status, track.kind === 'caption' && p.alignmentStatus[segment.alignment ?? 'none'], segment.audio && (wave === false ? p.waveformUnavailable : wave === undefined ? p.waveformLoading : undefined)].filter(Boolean).join(' · '),
|
|
50
|
+
waveform: wave ? wave.peaks : undefined,
|
|
51
|
+
}
|
|
52
|
+
}) })) : [{ id: 'visual', label: p.tracks.visual, segments: section?.stepOrder.map((id, index) => ({ id, stepId: id, label: document?.steps[id]?.title ?? p.stepNumber(index), timing: 'unknown' as const })) ?? [] }, { id: 'voice', label: p.tracks.voice, segments: [] }, { id: 'caption', label: p.tracks.caption, segments: [] }]
|
|
53
|
+
const captionChoices = current ? plan?.tracks.find(track => track.kind === 'caption')?.segments.filter(segment => segment.stepId === stepId && (segment.alignment === 'words' || segment.alignment === 'manual') && segment.timing === 'confirmed').map(segment => segment.id) ?? [] : []
|
|
54
|
+
return <PmTimeline variant="editor" label={p.timeline} tracks={tracks} labels={p.timelineLabels} durationMs={duration} durationStatus={page?.timing ?? 'unknown'} currentTimeMs={position} selectedStepId={stepId} showSteps={false}
|
|
55
|
+
segmentPicker={{ track: 'caption', ids: captionChoices, label: p.captionSegment, placeholder: p.chooseCaptionSegment }}
|
|
56
|
+
zoom={zoom} onZoomChange={setZoom} disabled={disabled} loading={loading} seeking={seeking} seekDisabled={!seekEnabled || start === undefined}
|
|
57
|
+
onSelectStep={onSelectStep} onSelectSegment={(track, selected) => {
|
|
58
|
+
const segment = current ? plan?.tracks.find(value => value.kind === track)?.segments.find(value => value.id === selected.id) : undefined
|
|
59
|
+
if (track === 'caption' && seekEnabled && (segment?.alignment === 'words' || segment?.alignment === 'manual') && segment.timing === 'confirmed' && segment.startMs !== undefined) onSeek(segment.startMs)
|
|
60
|
+
else if (selected.stepId) onSelectStep(selected.stepId)
|
|
61
|
+
}} onSeek={time => { if (start !== undefined) onSeek(start + time) }} />
|
|
62
|
+
}
|