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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,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
+ }