@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,151 @@
1
+ import type { Locale } from './messages.ts'
2
+
3
+ const zh = {
4
+ serviceSettings: '语音服务设置',
5
+ playOnDemand: '播放时按需生成配音并缓存;缺失讲稿会发送到所选语音服务。',
6
+ generatingPlayback: '正在生成当前配音,完成后自动播放。可暂停取消。',
7
+ generatingProgress: (done: number, total: number) => `正在生成配音 ${done} / ${total},完成后自动播放。`,
8
+ library: '页面与素材', pages: '页面', media: '音频素材', properties: '步骤属性', narration: '讲稿', voiceTab: '配音', captionsTab: '字幕',
9
+ searchPages: '搜索页面', pageResults: (count: number) => `${count} 页`, pageSteps: (count: number) => `${count} 个步骤`, noPages: '没有匹配的页面',
10
+ resizeLibrary: '调整页面列表与画面宽度', issueCount: (count: number) => `${count} 项问题`,
11
+ resizeProperties: '调整画面与讲稿宽度', resizeTimeline: '调整画面与时间线高度',
12
+ title: '视频制作', back: '返回文稿', preview: '画面', script: '讲稿与配音', outputs: '导出',
13
+ step: '当前步骤', page: '页面', prepare: '准备画面', preparing: '正在准备画面…',
14
+ current: '当前保存版本', outdated: '需要更新', unavailable: '画面尚未准备',
15
+ previewHint: '用当前保存版本准备画面后即可查看。讲稿和音色可单独编辑。',
16
+ voice: '音色与配音', choose: '选择音色', generate: '生成配音', none: '静音', unset: '未设置音色',
17
+ recording: '录音来源',
18
+ inherited: { step: '本步设置', page: '继承本页', document: '继承文稿默认', unset: '未设置' },
19
+ scopeShort: { step: '本步', steps: '多步', page: '本页', document: '文稿' },
20
+ counts: (updated: number, preserved: number, unchanged: number) => `更新 ${updated} 个步骤,${preserved} 个步骤保留覆盖,${unchanged} 个步骤不变。`,
21
+ overridden: (none: number, recording: number) => `范围内有 ${none} 项静音、${recording} 项录音独立设置。`,
22
+ replaceCount: (count: number) => `确认替换 ${count} 处覆盖`, keepAssets: '录音和其他素材仍保留,不会改写讲稿或生成配音。',
23
+ mute: '设置为静音', restore: '使用继承设置', selectSteps: '请至少选择一个步骤。',
24
+ draftChanged: '文稿已更新。设置草稿已保留,请重新检查范围后应用。',
25
+ applyTitle: '确认音色应用范围', restoreTitle: '确认使用继承设置',
26
+ applyHint: '只修改所选范围的音色来源,正文、讲稿和播放时机保留。',
27
+ restoreHint: '所选层级改用上一级的设置,保留的下级覆盖不变。清除文稿默认后将没有默认音色。',
28
+ catalogFailed: '无法加载音色列表,请重试。', cached: '当前讲稿可使用已有配音缓存。',
29
+ sourceReady: '只有确认保存版本与步骤后才能试听或生成。',
30
+ currentScript: '试听本步讲稿', selectedVoice: '所选音色', unknownSource: '未连接的服务',
31
+ playbackHint: '当前按步骤编辑;播放时长与字幕时间由实际讲授的时间线得出。',
32
+ timeline: '时间线', play: '播放', pause: '暂停', rate: '播放速度', planLoading: '正在加载时间线…',
33
+ enable: '启用视频制作', enableHint: '此文稿尚未启用视频制作。启用后可使用时间线并导出讲授,正文和讲稿保留;选择音色后,播放时会按需生成配音。',
34
+ planOutdated: '文稿已更新,正在准备对应的时间线。', planRetry: '重新加载时间线', playbackWaiting: '准备当前画面后可播放或定位到时间。',
35
+ playbackLoading: '正在准备播放…', playbackFailed: '无法完成播放或定位,请重试。', planFailed: '当前编排无法准备,请检查步骤、配音和动画时长。',
36
+ estimatedPlan: '未生成部分显示估计时长。播放时自动生成并缓存,生成后更新精确时间。',
37
+ unknownPlan: '当前编排仍有未确认的时序。请查看原因,修正后再按时间播放或导出。',
38
+ issues: (count: number) => `查看 ${count} 项编排问题`,
39
+ diagnosticFallback: '当前编排包含未支持或无效的绑定,请检查关联内容。',
40
+ diagnostics: {
41
+ 'production.animation-duration-unknown': '动画未声明有效时长,暂不能按时间播放或导出。',
42
+ 'production.recording-cue-unsupported': '原音频没有提示的确认时码,请移除跨步或内部暂停,并将提示放到本步起点。',
43
+ 'production.recording-start-conflict': '同一步讲稿的播放起点不一致,请先统一后采用原音频。',
44
+ 'production.recording-captions-unverified': '录音已裁剪,字幕仍为本步完整讲稿,尚未按原音对齐,请核对。',
45
+ 'production.recording-fingerprint-required': '请重新采用原音频以确认实际文件。',
46
+ 'production.recording-unsupported': '录音来源已保留,但暂时不能用于这份讲稿的播放。',
47
+ 'production.media-unsupported': '媒体片段已保留,暂不支持裁剪和混音。',
48
+ 'production.silent-cue-unknown': '静音讲稿中的文字提示没有确认时码;请放到步骤起点或提供音频。',
49
+ 'production.next-pause-unsupported': '跨步骤的同一段配音尚不能插入步骤停顿,请先拆成按步骤绑定的讲稿。',
50
+ 'production.cache-missing': '当前配音尚未缓存,点击播放后自动生成。',
51
+ 'production.voice-unset': '此讲稿尚未选择发声方式,请选择静音或实际音色。',
52
+ 'production.cue-invalid': '讲稿提示的目标或动作无效,请检查对应的讲稿和内容。',
53
+ },
54
+ timingDraft: '请先确认或修正尚未保存的播放时机。', captionDraft: '未应用字幕时间', captionStorageFailed: '无法备份字幕时间,请保持此页面打开。',
55
+ tracks: { visual: '画面', voice: '配音', caption: '字幕' },
56
+ captionSegment: '字幕片段', chooseCaptionSegment: '选择字幕片段',
57
+ alignmentStatus: { manual: '手动时间', words: '词时码', proportional: '比例估时', none: '未对齐' },
58
+ segmentStatus: { recorded: '原音频', cached: '已有配音', silent: '静音', missing: '配音待生成', blocked: '暂不可用' },
59
+ waveformLoading: '正在加载波形…', waveformUnavailable: '波形不可用', stepNumber: (index: number) => `步骤 ${index + 1}`,
60
+ timelineLabels: { position: '位置', seconds: '秒', seek: '播放头位置', zoomIn: '放大时间线', zoomOut: '缩小时间线', fit: '适配本页', estimated: '估计时长', unknown: '时长未知', loading: '正在加载时间线…', empty: '当前页暂无时间线片段', seekHint: '选择步骤,或用时间尺、方向键和精确时间定位;定位会先暂停播放。', invalidPosition: '请输入本页时长范围内的秒数。' },
61
+ failures: {
62
+ 'production-source-changed': '文稿已更新,旧播放请求已停止。请重新准备当前版本。',
63
+ 'production-binding-stale': '时间线已更新,请重新加载后再播放。',
64
+ 'production-plan-changed': '音频或时间线已更新,请重新加载后再播放。',
65
+ 'production-plan-unavailable': '当前时间线尚不能播放,请检查缺失的配音、动画时长或不支持的媒体设置。',
66
+ 'production-playback-unavailable': '画面尚未连接播放控制,请更新画面后重试。',
67
+ 'production-playback-timeout': '无法确认播放或定位,请重试。',
68
+ 'production-playback-time': '所选时间超出当前时间线,请重新定位。',
69
+ 'production-playback-rate': '当前播放速度不可用,请重新选择。',
70
+ 'production-playback-failed': '无法播放当前媒体,请检查音频能否读取。',
71
+ 'production-playback-cancelled': '播放请求已取消。',
72
+ },
73
+ }
74
+ export const PRODUCTION_MESSAGES: Record<Locale, typeof zh> = {
75
+ 'zh-CN': zh,
76
+ en: {
77
+ serviceSettings: 'Voice Service Settings',
78
+ playOnDemand: 'Play generates and caches missing voiceover using the selected speech service.',
79
+ generatingPlayback: 'Generating the current voiceover. Playback continues when ready; pause to cancel.',
80
+ generatingProgress: (done, total) => `Generating voiceover ${done} / ${total}. Playback continues when ready.`,
81
+ library: 'Pages and Assets', pages: 'Pages', media: 'Audio Assets', properties: 'Step Properties', narration: 'Script', voiceTab: 'Voiceover', captionsTab: 'Captions',
82
+ searchPages: 'Search Pages', pageResults: count => `${count} ${count === 1 ? 'page' : 'pages'}`, pageSteps: count => `${count} ${count === 1 ? 'step' : 'steps'}`, noPages: 'No matching pages',
83
+ resizeLibrary: 'Resize page list and visuals', issueCount: count => `${count} ${count === 1 ? 'Issue' : 'Issues'}`,
84
+ resizeProperties: 'Resize visuals and script', resizeTimeline: 'Resize visuals and timeline',
85
+ title: 'Video Production', back: 'Back to Document', preview: 'Visuals', script: 'Script and Voiceover', outputs: 'Export',
86
+ step: 'Current step', page: 'Page', prepare: 'Prepare Visuals', preparing: 'Preparing visuals…',
87
+ current: 'Current saved version', outdated: 'Update needed', unavailable: 'Visuals are not prepared',
88
+ previewHint: 'Prepare the saved version to see the visuals. Scripts and voices can be edited separately.',
89
+ voice: 'Voice and Voiceover', choose: 'Choose Voice', generate: 'Generate Voiceover', none: 'Silent', unset: 'No voice set',
90
+ recording: 'Recording source',
91
+ inherited: { step: 'This step', page: 'Inherited from page', document: 'Inherited from document', unset: 'Not set' },
92
+ scopeShort: { step: 'Step', steps: 'Selected', page: 'Page', document: 'Document' },
93
+ counts: (updated, preserved, unchanged) => `${updated} steps update, ${preserved} keep overrides, ${unchanged} stay unchanged.`,
94
+ overridden: (none, recording) => `This scope contains ${none} silent and ${recording} recording overrides.`,
95
+ replaceCount: count => `Replace ${count} ${count === 1 ? 'Override' : 'Overrides'}`, keepAssets: 'Recordings and other assets are kept. Scripts stay unchanged; no voiceover is generated.',
96
+ mute: 'Set to Silent', restore: 'Use Inherited Setting', selectSteps: 'Select at least one step.',
97
+ draftChanged: 'The document changed. Your settings draft is kept; check the scope again before applying.',
98
+ applyTitle: 'Confirm Voice Scope', restoreTitle: 'Use Inherited Setting',
99
+ applyHint: 'Only the voice source changes. Content, scripts, and timing are kept.',
100
+ restoreHint: 'The selected level uses its parent’s setting; overrides kept below it stay. Clearing the document default leaves no default voice.',
101
+ catalogFailed: 'Couldn’t load the list of voices. Try again.', cached: 'The current script can use existing cached voiceover.',
102
+ sourceReady: 'Confirm the saved version and step before listening or generating voiceover.',
103
+ currentScript: 'Play This Step’s Script', selectedVoice: 'Selected voice', unknownSource: 'Disconnected service',
104
+ playbackHint: 'Editing is organized by steps. Durations and caption times come from the lecture’s actual timeline.',
105
+ timeline: 'Timeline', play: 'Play', pause: 'Pause', rate: 'Playback speed', planLoading: 'Loading the timeline…',
106
+ enable: 'Turn On Video Production', enableHint: 'Video production is not turned on for this document. Turn it on to use the timeline and export the lecture; content and scripts are kept. Choose a voice, then Play generates missing voiceover.',
107
+ planOutdated: 'The document changed. Preparing its timeline.', planRetry: 'Reload Timeline', playbackWaiting: 'Prepare the current visuals to play or go to a time.',
108
+ playbackLoading: 'Preparing playback…', playbackFailed: 'Couldn’t finish playing or seeking. Try again.', planFailed: 'Couldn’t prepare the sequence. Check the steps, voiceover, and animation durations.',
109
+ estimatedPlan: 'Missing clips show estimated durations. Play generates and caches voiceover, then updates exact times.',
110
+ unknownPlan: 'Some timing is unconfirmed. Review the issues before timed playback or export.',
111
+ issues: count => `Review ${count} Sequence ${count === 1 ? 'Issue' : 'Issues'}`, diagnosticFallback: 'The sequence contains an unsupported or invalid link to content. Check the related content.',
112
+ diagnostics: {
113
+ 'production.animation-duration-unknown': 'The animation has no valid declared duration. Timed playback and export are unavailable.',
114
+ 'production.recording-cue-unsupported': 'The recording has no confirmed cue timing. Remove next-step cues and internal pauses, and place cues at the step start.',
115
+ 'production.recording-start-conflict': 'Scripts in this step have different starts. Use one common start before using the recording.',
116
+ 'production.recording-captions-unverified': 'The recording is trimmed. Captions still show the complete step script and are not aligned to the audio; review them.',
117
+ 'production.recording-fingerprint-required': 'Use the recording again to confirm the actual file.',
118
+ 'production.recording-unsupported': 'The recording source is kept but can’t be used to play this script yet.',
119
+ 'production.media-unsupported': 'Media clips are kept. Trimming and mixing are not available yet.',
120
+ 'production.silent-cue-unknown': 'Text-anchored cues in silent scripts have no confirmed timing. Move them to the step start or provide audio.',
121
+ 'production.next-pause-unsupported': 'One voiceover clip that spans steps can’t take step pauses yet. Split it into one script per step.',
122
+ 'production.cache-missing': 'This voiceover is not cached. Play generates it automatically.',
123
+ 'production.voice-unset': 'This script has no voice source. Choose silence or an actual voice.',
124
+ 'production.cue-invalid': 'A script cue has an invalid target or action. Check the script and its content.',
125
+ },
126
+ timingDraft: 'Confirm or correct the playback timing that is not saved first.', captionDraft: 'Caption times not applied', captionStorageFailed: 'Couldn’t back up caption times. Keep this page open.',
127
+ tracks: { visual: 'Visuals', voice: 'Voiceover', caption: 'Captions' },
128
+ captionSegment: 'Caption segment', chooseCaptionSegment: 'Choose Caption Segment',
129
+ alignmentStatus: { manual: 'Manual timing', words: 'Word timing', proportional: 'Proportional timing', none: 'Unaligned' },
130
+ segmentStatus: { recorded: 'Original audio', cached: 'Voiceover cached', silent: 'Silent', missing: 'Voiceover needed', blocked: 'Unavailable' },
131
+ waveformLoading: 'Loading waveform…', waveformUnavailable: 'Waveform unavailable', stepNumber: index => `Step ${index + 1}`,
132
+ timelineLabels: { position: 'Position', seconds: 'seconds', seek: 'Playhead position', zoomIn: 'Zoom In Timeline', zoomOut: 'Zoom Out Timeline', fit: 'Fit Page', estimated: 'Estimated duration', unknown: 'Unknown duration', loading: 'Loading the timeline…', empty: 'This page has no timeline segments', seekHint: 'Select a step, or use the ruler, arrow keys, or an exact time to seek. Seeking pauses playback first.', invalidPosition: 'Enter a time in seconds within this page’s duration.' },
133
+ failures: {
134
+ 'production-source-changed': 'The document changed and the old playback request stopped. Prepare the current version.',
135
+ 'production-binding-stale': 'The timeline changed. Reload it before playing.',
136
+ 'production-plan-changed': 'The audio or timeline changed. Reload it before playing.',
137
+ 'production-plan-unavailable': 'This timeline can’t be played yet. Check for missing voiceover, animation durations, or unsupported media settings.',
138
+ 'production-playback-unavailable': 'The visuals are not connected to playback controls. Update the visuals and try again.',
139
+ 'production-playback-timeout': 'Couldn’t confirm playing or seeking. Try again.',
140
+ 'production-playback-time': 'The selected time is outside the current timeline. Choose another position.',
141
+ 'production-playback-rate': 'This playback speed is unavailable. Choose another speed.',
142
+ 'production-playback-failed': 'Couldn’t play the current media. Check that its audio can be read.',
143
+ 'production-playback-cancelled': 'The playback request was canceled.',
144
+ },
145
+ },
146
+ }
147
+
148
+ export function productionFailure(error: unknown, locale: Locale): string {
149
+ const message = error instanceof Error ? error.message : String(error), t = PRODUCTION_MESSAGES[locale]
150
+ return (t.failures as Record<string, string>)[message] ?? t.playbackFailed
151
+ }
@@ -0,0 +1,109 @@
1
+ import type { PreviewReport, ProductionBinding, ProductionPlaybackCommand, ProductionPlaybackState } from '@lincyaw/kernel'
2
+ import { sameProductionBinding } from '@lincyaw/kernel'
3
+ import type { PreviewSink } from './workbench.ts'
4
+
5
+ interface Context {
6
+ document?: string
7
+ scope: number
8
+ binding?: ProductionBinding
9
+ ready: boolean
10
+ sinks: ReadonlySet<PreviewSink>
11
+ }
12
+ interface Pending {
13
+ request: number
14
+ document: string
15
+ scope: number
16
+ sink: PreviewSink
17
+ generation?: number
18
+ binding: ProductionBinding
19
+ selection?: { sectionId: string, stepId: string }
20
+ finish(state?: ProductionPlaybackState, error?: string): void
21
+ }
22
+
23
+ /** Owns command acknowledgements from one live frame; reported positions always come from its lecture clock. */
24
+ export class ProductionPlayback {
25
+ readonly #context: () => Context
26
+ readonly #publish: (state: ProductionPlaybackState | undefined) => void
27
+ #pending?: Pending
28
+ #owner?: { sink: PreviewSink, generation?: number, binding: ProductionBinding }
29
+ #sequence = 0
30
+
31
+ constructor(context: () => Context, publish: (state: ProductionPlaybackState | undefined) => void) {
32
+ this.#context = context; this.#publish = publish
33
+ }
34
+
35
+ /** A stable selection owns its target until the current frame has acknowledged it. */
36
+ selecting(sectionId?: string, stepId?: string): boolean {
37
+ const pending = this.#pending, context = this.#context()
38
+ return Boolean(pending?.selection && context.ready && pending.scope === context.scope && pending.document === context.document
39
+ && context.binding && sameProductionBinding(pending.binding, context.binding)
40
+ && (sectionId === undefined || pending.selection.sectionId === sectionId && pending.selection.stepId === stepId))
41
+ }
42
+
43
+ control(control: ProductionPlaybackCommand, signal?: AbortSignal): Promise<ProductionPlaybackState> {
44
+ if (signal?.aborted) return Promise.reject(signal.reason)
45
+ const context = this.#context(), sink = [...context.sinks].find(value => value.capabilities?.includes('production-playback'))
46
+ if (context.document === undefined || !context.ready || !context.binding || !sameProductionBinding(context.binding, control.binding)) return Promise.reject(new Error('production-source-changed'))
47
+ if (!sink) return Promise.reject(new Error('production-playback-unavailable'))
48
+ this.#pending?.finish(undefined, 'production-request-superseded')
49
+ const request = ++this.#sequence
50
+ this.#owner = { sink, generation: sink.generation, binding: control.binding }
51
+ return new Promise((resolve, reject) => {
52
+ const cancel = () => finish(undefined, 'production-request-cancelled')
53
+ const timer = setTimeout(() => finish(undefined, 'production-playback-timeout'), 15000)
54
+ const finish = (state?: ProductionPlaybackState, error?: string) => {
55
+ if (this.#pending?.request !== request) return
56
+ this.#pending = undefined
57
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel)
58
+ if (error || !state) { this.#release(); reject(new Error(error ?? 'production-playback-unavailable')) }
59
+ else { this.#publish(state); resolve(state) }
60
+ }
61
+ this.#pending = { request, document: context.document!, scope: context.scope, sink, generation: sink.generation, binding: control.binding,
62
+ ...control.action === 'step' ? { selection: { sectionId: control.sectionId, stepId: control.stepId } } : {}, finish }
63
+ signal?.addEventListener('abort', cancel, { once: true })
64
+ try { sink.send({ type: 'production-playback', request, control }) }
65
+ catch { finish(undefined, 'production-playback-unavailable') }
66
+ })
67
+ }
68
+
69
+ report(document: string, report: PreviewReport, sink?: PreviewSink): void {
70
+ if (report.type !== 'production-playback-state' && report.type !== 'production-playback-result') return
71
+ const context = this.#context(), owner = this.#owner
72
+ if (!sink || !owner || document !== context.document || !context.ready || !context.binding || !context.sinks.has(sink)
73
+ || owner.sink !== sink || owner.generation !== sink.generation || !sameProductionBinding(owner.binding, context.binding)) return
74
+ if (report.type === 'production-playback-state') {
75
+ // An older state event cannot move a newer pending seek's playhead before its acknowledgement.
76
+ if (!this.#pending && sameProductionBinding(report.state.binding, owner.binding)) this.#publish(report.state)
77
+ return
78
+ }
79
+ const pending = this.#pending
80
+ if (!pending || pending.request !== report.request || pending.scope !== context.scope || pending.document !== document || pending.sink !== sink
81
+ || pending.generation !== sink.generation || !sameProductionBinding(pending.binding, report.binding)) return
82
+ if (report.state && !sameProductionBinding(report.state.binding, pending.binding)) pending.finish(undefined, 'production-source-changed')
83
+ else pending.finish(report.state, report.error)
84
+ }
85
+
86
+ /** Source edits or a replaced plan invalidate pending work before any old callback can be published. */
87
+ changed(): boolean {
88
+ const context = this.#context()
89
+ if (!this.#owner || context.ready && context.binding && sameProductionBinding(context.binding, this.#owner.binding)) return false
90
+ this.reset(); return true
91
+ }
92
+
93
+ detach(sink: PreviewSink): void { if (this.#owner?.sink === sink) this.reset() }
94
+
95
+ reset(): void {
96
+ if (this.#pending) { this.#pending.finish(undefined, 'production-source-changed'); return }
97
+ this.#release()
98
+ }
99
+
100
+ #release(): void {
101
+ const owner = this.#owner
102
+ this.#owner = undefined
103
+ // Cancelling a parent request also supersedes its asynchronous work in the child.
104
+ if (owner) {
105
+ try { owner.sink.send({ type: 'production-playback', request: ++this.#sequence, control: { binding: owner.binding, action: 'pause' } }) } catch {}
106
+ }
107
+ this.#publish(undefined)
108
+ }
109
+ }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { sameProductionBinding } from '@lincyaw/kernel'
3
+ import type { ProductionPlan, ProductionPlaybackPosition, VoiceoverJob } from '@lincyaw/kernel'
4
+ import { useWorkbench, useWorkbenchState } from './context.tsx'
5
+ import { prepareVoiceSnapshot, voiceSnapshotCurrent } from './voice-snapshot.ts'
6
+ import { voiceFailure } from './voice-messages.ts'
7
+ import type { Workbench, WorkbenchState } from './workbench.ts'
8
+ import { isAgentChanging } from './workbench.ts'
9
+
10
+ const running = (job: VoiceoverJob) => ['queued', 'running', 'cancelling'].includes(job.state)
11
+
12
+ /** Observes the existing owners; it has no media timer, task ledger, or source writer. */
13
+ function observeUntil<T>(workbench: Workbench, signal: AbortSignal, read: (state: WorkbenchState) => T | undefined): Promise<T> {
14
+ return new Promise((resolve, reject) => {
15
+ let stop = () => {}
16
+ const finish = (error?: unknown, value?: T) => { stop(); signal.removeEventListener('abort', abort); error ? reject(error) : resolve(value!) }
17
+ const abort = () => finish(signal.reason ?? new DOMException('Cancelled', 'AbortError'))
18
+ const check = () => { try { signal.throwIfAborted(); const value = read(workbench.getState()); if (value !== undefined) finish(undefined, value) } catch (error) { finish(error) } }
19
+ stop = workbench.subscribe(check); signal.addEventListener('abort', abort, { once: true }); check()
20
+ })
21
+ }
22
+
23
+ /** A click grants one cancellable playback intent; missing clips use the shared voiceover job and cache. */
24
+ export function useProductionVoicePlayback() {
25
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale)
26
+ const plan = useWorkbenchState(state => state.productionPlan), playback = useWorkbenchState(state => state.productionPlayback)
27
+ const source = useWorkbenchState(state => state.authoring), serviceVersion = useWorkbenchState(state => state.voiceServiceVersion)
28
+ const navigation = useWorkbenchState(state => state.productionNavigationVersion)
29
+ const blocked = useWorkbenchState(state => Boolean(state.hasDocumentTimingDrafts || state.documentTimingDraftStorageFailure || isAgentChanging(state)))
30
+ const [busy, setBusy] = useState(false), [starting, setStarting] = useState(false), [failure, setFailure] = useState<string>(), [progress, setProgress] = useState<{ completed: number, total: number }>()
31
+ const request = useRef<AbortController>(), startRequest = useRef<AbortController>(), intent = useRef(false), mounted = useRef(true), epoch = useRef(0)
32
+ const stop = () => { epoch.current++; intent.current = false; startRequest.current?.abort(); startRequest.current = undefined; request.current?.abort(); request.current = undefined; if (mounted.current) { setBusy(false); setStarting(false); setProgress(undefined) } }
33
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; stop() } }, [workbench])
34
+ useEffect(() => { stop() }, [source?.identity, source?.version, source?.fingerprint, source?.projectRevision, serviceVersion, navigation])
35
+ useEffect(() => { if (blocked || source?.dirty || source?.saving || source?.composing || source?.restored || source?.error) stop() }, [blocked, source?.dirty, source?.saving, source?.composing, source?.restored, source?.error])
36
+
37
+ const generate = async (oldPlan: ProductionPlan, position: ProductionPlaybackPosition) => {
38
+ if (request.current || !intent.current || !position.waitingFor) return
39
+ const controller = new AbortController(); request.current = controller
40
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(180000)])
41
+ const initial = workbench.getState(), directory = initial.document, navigation = initial.productionNavigationVersion, project = initial.authoring?.projectRevision
42
+ let job: VoiceoverJob | undefined, settled = false, stopProgress = () => {}
43
+ setBusy(true); setFailure(undefined); setProgress(undefined)
44
+ try {
45
+ const segment = oldPlan.tracks.find(track => track.kind === 'voice')?.segments.find(segment => segment.utteranceId === position.waitingFor)
46
+ if (!segment || !position.stepId || !position.sectionId) throw new Error('voice-context-changed')
47
+ const stepId = segment.sourceStepId ?? segment.stepId
48
+ const snapshot = await prepareVoiceSnapshot(workbench, stepId, signal)
49
+ if (directory === undefined || snapshot.directory !== directory || snapshot.source.version !== oldPlan.binding.source.version || snapshot.source.fingerprint !== oldPlan.binding.source.fingerprint) throw new Error('voice-context-changed')
50
+ const status = await workbench.client.readVoiceover(directory, signal)
51
+ if (!voiceSnapshotCurrent(workbench, snapshot) || status.revision !== snapshot.revision) throw new Error('voice-context-changed')
52
+ const selected = status.steps.find(step => step.stepId === stepId)
53
+ const unbound = status.unboundByPage?.find(page => page.section === segment.sectionId)
54
+ const pageScope = (segment.stepIds?.length ?? 0) > 1 || Boolean(unbound?.utterances)
55
+ const target = pageScope ? unbound : selected
56
+ if (target?.state === 'blocked') throw new Error(target.reason ?? 'voice-unavailable')
57
+ if (segment.status === 'blocked' && !target?.cached) throw new Error(target?.reason ?? 'voice-unset')
58
+ const current = () => {
59
+ signal.throwIfAborted(); const state = workbench.getState()
60
+ if (!intent.current || !voiceSnapshotCurrent(workbench, snapshot) || state.productionNavigationVersion !== navigation || state.authoring?.projectRevision !== project || state.productionPlan?.binding.irRevision !== snapshot.revision) {
61
+ controller.abort(); throw new Error('voice-context-changed')
62
+ }
63
+ }
64
+ current()
65
+ if (target?.state !== 'ready' && target?.state !== 'empty') {
66
+ job = await workbench.client.startVoiceover(directory, { source: snapshot.source, revision: snapshot.revision, scope: pageScope ? { kind: 'page', section: segment.sectionId } : { kind: 'step', target: snapshot.target }, confirmed: true }, signal)
67
+ current(); workbench.rememberVoiceoverJob(directory, job)
68
+ stopProgress = workbench.subscribe(() => { const latest = workbench.getState().voiceoverJobs?.find(value => value.id === job?.id); if (latest && mounted.current) setProgress({ completed: latest.completed, total: latest.total }) })
69
+ const finished = running(job) ? await observeUntil(workbench, signal, state => { current(); const latest = state.voiceoverJobs?.find(value => value.id === job!.id); return latest && !running(latest) ? latest : undefined }) : job
70
+ settled = true
71
+ if (finished.state !== 'done') throw new Error(finished.error ?? (finished.state === 'stale' ? 'voice-context-changed' : 'voice-generation-failed'))
72
+ }
73
+ current()
74
+ // The ordinary job observer refreshes the plan in the mounted frame. A concurrent hit has no local job.
75
+ if (!job) { await workbench.prepareProductionPlan(signal); current() }
76
+ const refreshed = await observeUntil(workbench, signal, state => {
77
+ current()
78
+ const next = state.productionPlan, live = state.productionPlayback
79
+ return next && next.binding.irRevision === snapshot.revision && next.binding.planId !== oldPlan.binding.planId && live?.status === 'ready' && sameProductionBinding(next.binding, live.binding) ? next : undefined
80
+ })
81
+ current()
82
+ await workbench.controlProduction({ binding: refreshed.binding, action: 'step', sectionId: position.sectionId, stepId: position.stepId, utteranceId: position.waitingFor }, signal)
83
+ current()
84
+ if (position.rate !== 1) await workbench.controlProduction({ binding: refreshed.binding, action: 'rate', rate: position.rate }, signal)
85
+ current()
86
+ await workbench.controlProduction({ binding: refreshed.binding, action: 'play' }, signal)
87
+ } catch (error) {
88
+ if (!controller.signal.aborted && mounted.current) { intent.current = false; setFailure(voiceFailure(error, locale)) }
89
+ } finally {
90
+ stopProgress()
91
+ if (job && !settled) void workbench.client.cancelVoiceover(directory!, job.id, AbortSignal.timeout(5000)).catch(() => {})
92
+ if (request.current === controller) { request.current = undefined; if (mounted.current) { setBusy(false); setProgress(undefined) } }
93
+ }
94
+ }
95
+ useEffect(() => {
96
+ if (intent.current && !busy && !starting && plan && playback?.position?.waitingFor && sameProductionBinding(plan.binding, playback.binding)) void generate(plan, playback.position)
97
+ }, [plan, playback, busy, starting])
98
+
99
+ const toggle = async () => {
100
+ const state = workbench.getState(), current = state.productionPlan
101
+ if (!current) return
102
+ if (request.current || startRequest.current || state.productionPlayback?.position?.playing) {
103
+ stop()
104
+ try { await workbench.controlProduction({ binding: current.binding, action: 'pause' }) } catch { /* The cancelled binding may already have been replaced. */ }
105
+ return
106
+ }
107
+ const controller = new AbortController(), owner = ++epoch.current
108
+ startRequest.current = controller; intent.current = true; setStarting(true); setFailure(undefined)
109
+ try { await workbench.controlProduction({ binding: current.binding, action: 'play' }, controller.signal) }
110
+ catch (error) { if (owner === epoch.current && !controller.signal.aborted) { intent.current = false; if (mounted.current) setFailure(voiceFailure(error, locale)) } }
111
+ finally { if (startRequest.current === controller) { startRequest.current = undefined; if (mounted.current) setStarting(false) } }
112
+ }
113
+ return { busy: busy || starting, failure, progress, stop, toggle }
114
+ }
@@ -0,0 +1,33 @@
1
+ import type { DocumentAnswer, DocumentRecordingAdoption, TeachingDocument, VoiceSource } from '@lincyaw/kernel'
2
+
3
+ const same = (a: unknown, b: unknown): boolean => {
4
+ if (a === b) return true
5
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
6
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => same(value, b[index]))
7
+ const left = a as Record<string, unknown>, right = b as Record<string, unknown>, keys = Object.keys(left)
8
+ return keys.length === Object.keys(right).length && keys.every(key => Object.hasOwn(right, key) && same(left[key], right[key]))
9
+ }
10
+ function source(document: TeachingDocument, stepId: string): VoiceSource | undefined {
11
+ const step = document.steps[stepId]
12
+ return step && (document.production.stepOverrides[stepId]?.voiceSource ?? document.production.sectionOverrides?.[step.sectionId]?.voiceSource ?? document.production.defaults.voiceSource)
13
+ }
14
+
15
+ /** Accept only the submitted recording delta; later author input remains the sole local draft. */
16
+ export function reconcileRecordingAdoption(request: DocumentRecordingAdoption, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
17
+ if (before.id !== request.source.documentId || current.id !== before.id || answer.content.id !== before.id) return
18
+ const step = before.steps[request.stepId], asset = before.assets[request.assetId]
19
+ if (!step || asset?.kind !== 'audio') return
20
+ const expected = structuredClone(before)
21
+ const voice: VoiceSource = { type: 'recording', assetId: asset.id, ...request.sourceRange ? { sourceRange: { ...request.sourceRange } } : {} }
22
+ expected.assets[asset.id]!.fingerprint = `sha256:${request.sha256}`
23
+ expected.production.stepOverrides[step.id] = { ...expected.production.stepOverrides[step.id], voiceSource: voice }
24
+ if (!same(expected, answer.content)) return
25
+ const content = structuredClone(current), localAsset = content.assets[asset.id]
26
+ const assetUnchanged = same(localAsset, asset) || same(localAsset, expected.assets[asset.id])
27
+ if (assetUnchanged) content.assets[asset.id]!.fingerprint = `sha256:${request.sha256}`
28
+ const own = (document: TeachingDocument) => document.production.stepOverrides[step.id]?.voiceSource
29
+ if (assetUnchanged && content.steps[step.id] && same(own(current), own(before)) && same(source(current, step.id), source(before, step.id))) {
30
+ content.production.stepOverrides[step.id] = { ...content.production.stepOverrides[step.id], voiceSource: voice }
31
+ }
32
+ return content
33
+ }
@@ -0,0 +1,187 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import Form from '@rjsf/core'
4
+ import { createSchemaUtils } from '@rjsf/utils'
5
+ import type { RJSFSchema } from '@rjsf/utils'
6
+ import { validateDocumentParameters, documentParameterAccess, documentParameterSchemaForAccess } from '@lincyaw/kernel'
7
+ import type { DocumentParameterValidation, JsonSchema, JsonValue } from '@lincyaw/kernel'
8
+ import { PmNotice } from '../components.tsx'
9
+ import type { ControlDensity } from '../components.tsx'
10
+ import { SCHEMA_FORM_MESSAGES } from './context.ts'
11
+ import type { FieldPathList, SchemaFormContext } from './context.ts'
12
+ import { declaredSchemaProperties, schemaFormFields } from './fields.tsx'
13
+ import { schemaFormTemplates } from './templates.tsx'
14
+ import { schemaFormWidgets } from './widgets.tsx'
15
+ import { schemaFormValidator } from './validator.ts'
16
+
17
+ export interface PmSchemaFormProps {
18
+ schema: JsonSchema
19
+ value: JsonValue
20
+ onChange(value: JsonValue, validation: DocumentParameterValidation): void
21
+ onValidationChange?(validation: DocumentParameterValidation): void
22
+ onComposition?(composing: boolean): void
23
+ disabled?: boolean
24
+ readOnly?: boolean
25
+ density?: ControlDensity
26
+ locale?: 'zh-CN' | 'en'
27
+ label?: string
28
+ error?: ReactNode
29
+ extraValidation?(value: JsonValue): DocumentParameterValidation
30
+ /** Independent roots used for field access only; validity still belongs to extraValidation. */
31
+ accessorSchemas?: readonly JsonSchema[]
32
+ hideProperties?: readonly string[]
33
+ empty?: ReactNode
34
+ idPrefix?: string
35
+ }
36
+ const object = (value: unknown): value is Record<string, any> => Boolean(value && typeof value === 'object' && !Array.isArray(value))
37
+ const own = (value: unknown, key: string | number) => value !== null && typeof value === 'object' && Object.hasOwn(value, key)
38
+ const at = (value: unknown, path: FieldPathList): unknown => path.reduce((current: unknown, key) => own(current, key) ? (current as Record<string, unknown>)[key] : undefined, value)
39
+ // RJSF represents unanswered fields with undefined; the authoring value contains JSON only.
40
+ function jsonValue(value: unknown): unknown {
41
+ if (Array.isArray(value)) return value.map(item => item === undefined ? null : jsonValue(item))
42
+ if (object(value)) return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined).map(([key, item]) => [key, jsonValue(item)]))
43
+ return value
44
+ }
45
+ function replace(value: JsonValue, path: FieldPathList, next: unknown): JsonValue {
46
+ if (!path.length) return (next ?? null) as JsonValue
47
+ const result = structuredClone(value), last = path.at(-1)!
48
+ let parent: any = result
49
+ for (let index = 0; index < path.length - 1; index++) {
50
+ const key = path[index]!
51
+ if (!own(parent, key) || parent[key] === null || typeof parent[key] !== 'object') Object.defineProperty(parent, key, { value: typeof path[index + 1] === 'number' ? [] : {}, enumerable: true, configurable: true, writable: true })
52
+ parent = parent[key]
53
+ }
54
+ if (next === undefined && !Array.isArray(parent)) delete parent[last]
55
+ else Object.defineProperty(parent, last, { value: next === undefined ? null : structuredClone(next), enumerable: true, configurable: true, writable: true })
56
+ return result
57
+ }
58
+
59
+ /** Remove data-producing annotations only from schema positions, never from authored data. */
60
+ function withoutDefaults(schema: JsonSchema): RJSFSchema {
61
+ if (typeof schema === 'boolean') return { allOf: [schema] } as RJSFSchema
62
+ const result = structuredClone(schema) as RJSFSchema
63
+ delete result.default
64
+ for (const key of ['$defs', 'definitions', 'properties', 'patternProperties', 'dependentSchemas'] as const) {
65
+ const entries = result[key]
66
+ if (object(entries)) result[key] = Object.fromEntries(Object.entries(entries).map(([name, child]) => [name, withoutDefaults(child)]))
67
+ }
68
+ for (const key of ['items', 'additionalProperties', 'additionalItems', 'contains', 'not', 'if', 'then', 'else', 'unevaluatedProperties', 'unevaluatedItems'] as const) {
69
+ const child = result[key]
70
+ if (typeof child === 'boolean' || object(child)) result[key] = withoutDefaults(child) as any
71
+ else if (Array.isArray(child)) result[key] = child.map(withoutDefaults) as any
72
+ }
73
+ for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems'] as const) if (Array.isArray(result[key])) result[key] = result[key]!.map(withoutDefaults)
74
+ return result
75
+ }
76
+
77
+ /** Top-level fields resolved through the same public schema utilities used by the form. */
78
+ export function schemaFormPropertyNames(schema: JsonSchema, value: JsonValue): { available: boolean, names: string[] } {
79
+ try {
80
+ const rendered = withoutDefaults(documentParameterSchemaForAccess(schema)), validator = schemaFormValidator(rendered)
81
+ const resolved = createSchemaUtils(validator, rendered).retrieveSchema(rendered, value)
82
+ // A selector's uncommitted branch is UI state, so it cannot be inferred from the saved value.
83
+ if (resolved.oneOf || resolved.anyOf) return { available: false, names: [] }
84
+ return { available: true, names: declaredSchemaProperties(resolved) }
85
+ } catch { return { available: false, names: [] } }
86
+ }
87
+
88
+ /** Controlled parameter fields. The caller owns drafts, scope, persistence and adoption. */
89
+ export function PmSchemaForm({ schema, value, onChange, onValidationChange, onComposition, extraValidation, accessorSchemas = [], hideProperties = [], empty, idPrefix, disabled = false, readOnly = false, density = 'compact', locale = 'zh-CN', label, error }: PmSchemaFormProps) {
90
+ const id = useId(), current = useRef({ schema, value, onChange, onValidationChange, onComposition, extraValidation, accessorSchemas, disabled, readOnly })
91
+ current.current = { schema, value, onChange, onValidationChange, onComposition, extraValidation, accessorSchemas, disabled, readOnly }
92
+ const [inputErrors, setInputErrors] = useState<Record<string, string>>({}), [composing, setComposing] = useState(false)
93
+ const composition = useRef(false)
94
+ const reportedValidation = useRef<string>()
95
+ const view = useMemo(() => { try { return { available: true, schema: withoutDefaults(documentParameterSchemaForAccess(schema)) } } catch { return { available: false, schema: {} as RJSFSchema } } }, [schema])
96
+ const rendered = view.schema, validator = useMemo(() => schemaFormValidator(rendered), [rendered])
97
+ const utilities = useMemo(() => createSchemaUtils(validator, rendered), [validator, rendered])
98
+ const validate = (next: JsonValue) => {
99
+ const base = validateDocumentParameters(current.current.schema, next), extra = current.current.extraValidation?.(next)
100
+ return extra ? { available: base.available && extra.available, valid: base.valid && extra.valid, issues: [...base.issues, ...extra.issues] } : base
101
+ }
102
+ const validation = useMemo(() => validate(value), [schema, value, extraValidation])
103
+ const context = useMemo<SchemaFormContext>(() => ({ density, messages: SCHEMA_FORM_MESSAGES[locale], paths: new Map(), schemas: new Map(), readOnly: new Map(),
104
+ mark(control) {
105
+ const branch = control.replace(/__(?:oneof|anyof)_select$/, '')
106
+ this.intent = this.paths.get(control) ?? this.paths.get(branch)
107
+ this.intentReadOnly = this.readOnly.get(control) ?? this.readOnly.get(branch)
108
+ this.branch = branch !== control ? branch : undefined
109
+ },
110
+ inputError(control, message) { setInputErrors(errors => errors[control] === message || !message && !Object.hasOwn(errors, control) ? errors : message ? { ...errors, [control]: message } : Object.fromEntries(Object.entries(errors).filter(([key]) => key !== control))) },
111
+ access(path) {
112
+ const latest = current.current, primary = documentParameterAccess(latest.schema, latest.value, path)
113
+ // RJSF's public SchemaField carries the explicitly selected UI branch while a draft is incomplete.
114
+ // Schema/reference failures never use this path; the canonical validator must still be available.
115
+ const draft = primary.reason === 'value-unresolved' || primary.available && !primary.public ? validateDocumentParameters(latest.schema, latest.value) : undefined
116
+ const pointer = path.length ? `/${path.map(key => String(key).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}` : ''
117
+ // Unlike oneOf, the accessor may silently omit an anyOf branch with unanswered required fields.
118
+ const unanswered = draft?.issues.some(issue => issue.keyword === 'anyOf' && (issue.path === '' || pointer === issue.path || pointer.startsWith(`${issue.path}/`)))
119
+ const selected = Boolean(draft?.available && (primary.reason === 'value-unresolved' || unanswered))
120
+ const additional = latest.accessorSchemas.map(root => documentParameterAccess(root, latest.value, path))
121
+ return { available: (primary.available || selected) && additional.every(result => result.available), public: primary.public || selected || additional.some(result => result.public), readOnly: primary.readOnly || additional.some(result => result.readOnly) }
122
+ },
123
+ hideProperties: new Set(hideProperties), empty,
124
+ }), [density, locale, JSON.stringify(hideProperties), empty])
125
+ const result = useMemo<DocumentParameterValidation>(() => {
126
+ const issues = [...validation.issues, ...Object.values(inputErrors).map(message => ({ path: '', keyword: 'input', message })), ...composing ? [{ path: '', keyword: 'composing', message: context.messages.composing }] : []]
127
+ return { ...validation, valid: validation.valid && !issues.length, issues }
128
+ }, [validation, inputErrors, composing, context.messages.composing])
129
+ useEffect(() => {
130
+ const serialized = JSON.stringify(result)
131
+ if (!current.current.onValidationChange || serialized === reportedValidation.current) return
132
+ reportedValidation.current = serialized
133
+ current.current.onValidationChange(result)
134
+ }, [result, onValidationChange])
135
+ useEffect(() => () => { if (composition.current) current.current.onComposition?.(false) }, [])
136
+
137
+ const preserve = (before: unknown, after: unknown, scope: RJSFSchema, depth = 0): unknown => {
138
+ if (depth > 32) throw new Error('schema-form-depth')
139
+ if (scope.readOnly) return structuredClone(before)
140
+ if (!object(before) || !object(after)) return after
141
+ const fields: Record<string, RJSFSchema> = {}
142
+ const collect = (candidate: RJSFSchema, level: number) => {
143
+ if (level > 32) throw new Error('schema-form-depth')
144
+ const resolved = utilities.retrieveSchema(candidate, after)
145
+ for (const key of declaredSchemaProperties(resolved)) fields[key] = resolved.properties![key] as RJSFSchema
146
+ for (const key of ['allOf', 'oneOf', 'anyOf'] as const) for (const branch of resolved[key] ?? []) if (typeof branch !== 'boolean') collect(branch, level + 1)
147
+ }
148
+ collect(scope, depth)
149
+ const merged = structuredClone(before)
150
+ for (const [key, child] of Object.entries(fields)) {
151
+ if (typeof child === 'boolean' || child.readOnly) continue
152
+ if (!Object.hasOwn(after, key)) delete merged[key]
153
+ else Object.defineProperty(merged, key, { value: preserve(before[key], after[key], child, depth + 1), enumerable: true, configurable: true, writable: true })
154
+ }
155
+ return merged
156
+ }
157
+ if (!validation.available || !view.available) return <PmNotice tone="warn">{context.messages.unsupported}</PmNotice>
158
+ return <div className="pm-ui-schema-form" data-density={density} role="group" aria-label={label}
159
+ onCompositionStart={() => { composition.current = true; setComposing(true); current.current.onComposition?.(true) }}
160
+ onCompositionEnd={() => { composition.current = false; setComposing(false); current.current.onComposition?.(false) }}>
161
+ {error && <PmNotice tone="danger">{error}</PmNotice>}
162
+ <Form schema={rendered} formData={value} validator={validator} widgets={schemaFormWidgets} templates={schemaFormTemplates} fields={schemaFormFields} formContext={context}
163
+ idPrefix={idPrefix ?? `parameters-${id}`} tagName="div" noHtml5Validate showErrorList={false} liveValidate disabled={disabled} readonly={readOnly}
164
+ experimental_defaultFormStateBehavior={{ emptyObjectFields: 'skipDefaults', arrayMinItems: { populate: 'never' }, constAsDefaults: 'never', requiredBooleanDefault: 'skip' }}
165
+ uiSchema={{ 'ui:options': { expandable: false } }} onChange={event => {
166
+ const latest = current.current, path = context.intent, branch = context.branch, intentReadOnly = context.intentReadOnly
167
+ context.intent = undefined; context.branch = undefined; context.intentReadOnly = undefined
168
+ if (!path || latest.disabled || latest.readOnly || intentReadOnly) return
169
+ const access = context.access(path)
170
+ if (!access.available || !access.public || access.readOnly) return
171
+ let next = jsonValue(at(event.formData, path))
172
+ if (branch) {
173
+ let scope = rendered, data: unknown = latest.value
174
+ for (const key of path) {
175
+ const resolved = utilities.retrieveSchema(scope, data)
176
+ const child = Array.isArray(data) ? resolved.items : resolved.properties?.[key]
177
+ if (!child || typeof child === 'boolean' || Array.isArray(child)) throw new Error('schema-form-target-unavailable')
178
+ scope = child; data = own(data, key) ? (data as Record<string, unknown>)[key] : undefined
179
+ }
180
+ next = preserve(at(latest.value, path), next, scope)
181
+ }
182
+ const updated = replace(latest.value, path, next)
183
+ const checked = validate(updated)
184
+ latest.onChange(updated, composition.current ? { ...checked, valid: false, issues: [...checked.issues, { path: '', keyword: 'composing', message: context.messages.composing }] } : checked)
185
+ }}><span /></Form>
186
+ </div>
187
+ }