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