@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,61 @@
1
+ import type { TeachingDocument } from '@lincyaw/kernel'
2
+ import { stepTimingValue } from './step-production.ts'
3
+ import type { ProductionSource, StepDurationDraft, StepDurationKey } from './step-production.ts'
4
+
5
+ interface StoredDraft extends StepDurationDraft { baseValue?: number, baseSource: ProductionSource }
6
+ const KEYS = ['minimumHoldMs', 'trailingPauseMs'] as const
7
+
8
+ /** Only unaccepted form strings live here. Accepted timing remains in the document actor. */
9
+ export class DocumentTimingDrafts {
10
+ readonly #documents = new Map<string, Map<string, StoredDraft>>()
11
+ failed = false
12
+ #storage(identity: string): string { return `presmium.document-timing.v1:${identity}` }
13
+ #load(identity: string): Map<string, StoredDraft> {
14
+ const existing = this.#documents.get(identity)
15
+ if (existing) return existing
16
+ const values = new Map<string, StoredDraft>()
17
+ this.#documents.set(identity, values)
18
+ let raw: string | null = null
19
+ try {
20
+ raw = sessionStorage.getItem(this.#storage(identity))
21
+ const entries: unknown = JSON.parse(raw ?? '[]')
22
+ if (!Array.isArray(entries) || entries.length > 512) throw new Error('document-timing-draft-data')
23
+ for (const entry of entries) {
24
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !entry[1] || typeof entry[1] !== 'object') throw new Error('document-timing-draft-data')
25
+ const target: unknown = JSON.parse(entry[0]), value = entry[1] as StoredDraft
26
+ if (!Array.isArray(target) || typeof target[0] !== 'string' || !KEYS.includes(target[1]) || typeof value.text !== 'string' || value.text.length > 256 || !['step', 'section', 'document', 'unset'].includes(value.baseSource) || value.baseValue !== undefined && (typeof value.baseValue !== 'number' || !Number.isFinite(value.baseValue) || value.baseValue < 0)) throw new Error('document-timing-draft-data')
27
+ values.set(entry[0], { text: value.text, error: true, review: true, baseValue: value.baseValue, baseSource: value.baseSource })
28
+ }
29
+ } catch {
30
+ this.failed = true
31
+ if (raw !== null) try { sessionStorage.setItem(`${this.#storage(identity)}:unreadable`, raw) } catch { /* Keep the original slot untouched when storage is unavailable. */ }
32
+ }
33
+ if (!this.failed) for (const key of this.#documents.keys()) {
34
+ if (this.#documents.size <= 8) break
35
+ if (key !== identity) this.#documents.delete(key)
36
+ }
37
+ return values
38
+ }
39
+ get(identity: string, document: TeachingDocument, stepId: string, key: StepDurationKey): StepDurationDraft | undefined {
40
+ const draft = this.#load(identity).get(JSON.stringify([stepId, key]))
41
+ if (!draft) return undefined
42
+ const actual = stepTimingValue(document, stepId, key)
43
+ return { text: draft.text, error: draft.error, review: draft.review || draft.baseValue !== actual.value || draft.baseSource !== actual.source }
44
+ }
45
+ has(identity: string, document: TeachingDocument): boolean {
46
+ return [...this.#load(identity).keys()].some(target => Object.hasOwn(document.steps, JSON.parse(target)[0]))
47
+ }
48
+ set(identity: string, document: TeachingDocument, stepId: string, key: StepDurationKey, draft: StepDurationDraft | undefined): boolean {
49
+ const values = this.#load(identity), target = JSON.stringify([stepId, key]), previous = values.get(target), actual = stepTimingValue(document, stepId, key)
50
+ if (draft && draft.text.length > 256 || draft && !previous && values.size >= 512) { this.failed = true; return true }
51
+ if (previous?.text === draft?.text && previous?.error === draft?.error && !previous?.review && (!previous || previous.baseValue === actual.value && previous.baseSource === actual.source)) return false
52
+ if (draft) values.set(target, { ...draft, baseValue: actual.value, baseSource: actual.source })
53
+ else values.delete(target)
54
+ try {
55
+ if (values.size) sessionStorage.setItem(this.#storage(identity), JSON.stringify([...values]))
56
+ else sessionStorage.removeItem(this.#storage(identity))
57
+ this.failed = false
58
+ } catch { this.failed = true }
59
+ return true
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ export { changeDocumentVoice, documentVoiceStepIds, previewDocumentVoiceChange, resolveDocumentStepVoice } from '@lincyaw/kernel'
2
+ export type { DocumentStepVoice, DocumentVoiceImpact, DocumentVoiceOptions, DocumentVoiceScope } from '@lincyaw/kernel'
package/src/history.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import type { RevertResult, ReviewChange, ReviewSnapshot, ReviewUpdate } from '@lincyaw/kernel'
3
+ import type { WorkbenchStore } from './workbench.ts'
4
+ import { WorkspaceServerError } from './workspace-client.ts'
5
+ import type { WorkspaceClient } from './workspace-client.ts'
6
+
7
+ /**
8
+ * The open document's version history: its revisions with the next undo and redo, the files changed elsewhere and not
9
+ * yet in the history, and the AI's change in progress (followed while the document is open). Each action reads the
10
+ * history again. `changed` hears the AI's changes once each of its turns has made them: when the review waits for the
11
+ * author, or was accepted.
12
+ */
13
+ export class DocumentHistory {
14
+ readonly #client: WorkspaceClient
15
+ readonly #store: WorkbenchStore
16
+ readonly #changed: (changes: ReviewChange[]) => void
17
+ #reads = 0
18
+ #offsets = [0]
19
+ #page = 0
20
+ #readFailure?: string
21
+ #scope = 0
22
+ #unsubscribeReview?: () => void
23
+ #review?: ReviewSnapshot
24
+ #told?: string
25
+
26
+ constructor(client: WorkspaceClient, store: WorkbenchStore, changed: (changes: ReviewChange[]) => void = () => {}) {
27
+ this.#client = client
28
+ this.#store = store
29
+ this.#changed = changed
30
+ }
31
+
32
+ subscribeReview(dir: string): void {
33
+ const scope = this.#scope
34
+ this.#unsubscribeReview = this.#client.subscribeReview(dir, event => { if (scope === this.#scope) this.#handleReviewUpdate(dir, event) })
35
+ }
36
+
37
+ stop(): void {
38
+ this.#scope++
39
+ this.#readFailure = undefined
40
+ this.#unsubscribeReview?.()
41
+ this.#unsubscribeReview = undefined
42
+ this.#review = undefined
43
+ }
44
+
45
+ async read(dir: string): Promise<void> {
46
+ this.#offsets = [0]
47
+ this.#page = 0
48
+ const read = ++this.#reads, scope = this.#scope
49
+ const current = () => this.#store.state.document === dir && read === this.#reads && scope === this.#scope
50
+ try {
51
+ const [history, { files }] = await Promise.all([this.#client.readRevisions(dir), this.#client.readChanges(dir)])
52
+ if (current()) {
53
+ const historyFailure = this.#store.state.historyFailure
54
+ this.#store.set({ history, outside: files, ...this.#readFailure === historyFailure ? { historyFailure: undefined } : {} })
55
+ this.#readFailure = undefined
56
+ }
57
+ } catch (error) {
58
+ if (current()) {
59
+ this.#readFailure = errorMessage(error)
60
+ const history = this.#store.state.history
61
+ this.#store.set({ ...history ? { history: { ...history, loading: false } } : {}, historyFailure: this.#readFailure })
62
+ }
63
+ }
64
+ }
65
+
66
+ async page(offset: number): Promise<void> {
67
+ const dir = this.#store.state.document, before = this.#store.state.history
68
+ if (dir === undefined || !before?.historyBinding || before.loading || this.#store.state.isHistoryBusy || ![before.nextOffset, before.previousOffset].includes(offset)) return
69
+ const read = ++this.#reads, scope = this.#scope
70
+ const current = () => read === this.#reads && scope === this.#scope && this.#store.state.document === dir
71
+ this.#store.set({ history: { ...before, loading: true } })
72
+ try {
73
+ const history = await this.#client.readRevisions(dir, { snapshot: before.historyBinding.snapshot, offset })
74
+ if (!current()) return
75
+ let index = this.#offsets.indexOf(offset)
76
+ if (index < 0) { this.#offsets = [...this.#offsets.slice(0, this.#page + 1), offset]; index = this.#offsets.length - 1 }
77
+ this.#page = index
78
+ this.#readFailure = undefined
79
+ this.#store.set({ history: { ...history, ...index > 0 ? { previousOffset: this.#offsets[index - 1] } : {} }, historyFailure: undefined })
80
+ } catch (error) {
81
+ if (!current()) return
82
+ if (error instanceof WorkspaceServerError && error.status === 409) { await this.read(dir); return }
83
+ this.#readFailure = errorMessage(error)
84
+ this.#store.set({ history: { ...before, loading: false }, historyFailure: this.#readFailure })
85
+ }
86
+ }
87
+
88
+ #handleReviewUpdate(dir: string, event: ReviewUpdate): void {
89
+ if (this.#store.state.document !== dir) return
90
+ this.#store.set({ review: event.review })
91
+ if (event.review) this.#review = event.review
92
+ const settled = event.review && !event.review.running ? event.review : event.ended?.outcome === 'accepted' ? this.#review : undefined
93
+ // One review spans the turns until it is accepted; each turn's changes are told once.
94
+ const turn = settled && `${settled.id}:${settled.turns.to}`
95
+ if (settled && turn !== this.#told && settled.changes.length > 0) {
96
+ this.#told = turn
97
+ this.#changed(settled.changes)
98
+ }
99
+ if (event.ended) {
100
+ this.#review = undefined
101
+ void this.read(dir)
102
+ }
103
+ }
104
+
105
+ async #run(work: (dir: string) => Promise<RevertResult | unknown>): Promise<boolean> {
106
+ const dir = this.#store.state.document
107
+ if (dir === undefined || this.#store.state.isHistoryBusy) return false
108
+ const scope = this.#scope
109
+ const current = () => scope === this.#scope && dir === this.#store.state.document
110
+ this.#readFailure = undefined
111
+ this.#store.set({ isHistoryBusy: true })
112
+ try {
113
+ const answer = await work(dir)
114
+ if (!current()) return false
115
+ if (isFailedRevert(answer)) throw new Error(answer.conflicts.join(', '))
116
+ this.#store.set({ historyFailure: undefined })
117
+ return true
118
+ } catch (error) {
119
+ if (current()) this.#store.set({ historyFailure: errorMessage(error) })
120
+ return false
121
+ } finally {
122
+ if (current()) {
123
+ this.#store.set({ isHistoryBusy: false })
124
+ await this.read(dir)
125
+ }
126
+ }
127
+ }
128
+
129
+ #change(which: 'undo' | 'redo' | 'revertRevision', expect: string | undefined): Promise<boolean> {
130
+ const binding = this.#store.state.history?.historyBinding
131
+ if (!binding || !expect) return Promise.resolve(false)
132
+ const request = { id: crypto.randomUUID(), ...binding, expect }
133
+ return this.#run(dir => this.#client[which](dir, request))
134
+ }
135
+
136
+ undo(): Promise<boolean> {
137
+ const expect = this.#store.state.history?.undo
138
+ return this.#change('undo', expect)
139
+ }
140
+
141
+ redo(): Promise<boolean> {
142
+ const expect = this.#store.state.history?.redo
143
+ return this.#change('redo', expect)
144
+ }
145
+
146
+ /** Takes back one revision of the history, whether or not it is the latest. */
147
+ revert(id: string): Promise<boolean> {
148
+ return this.#change('revertRevision', id)
149
+ }
150
+
151
+ acceptReview(): Promise<boolean> {
152
+ return this.#run(dir => this.#client.acceptReview(dir, {}))
153
+ }
154
+
155
+ rejectReview(): Promise<boolean> {
156
+ return this.#run(dir => this.#client.rejectReview(dir))
157
+ }
158
+
159
+ /** Makes the files changed elsewhere one revision of the history. */
160
+ recordOutside(message: string): Promise<boolean> {
161
+ return this.#run(dir => this.#client.commitChanges(dir, message))
162
+ }
163
+ }
164
+
165
+ function isFailedRevert(answer: unknown): answer is Extract<RevertResult, { ok: false }> {
166
+ return typeof answer === 'object' && answer !== null && (answer as { ok?: unknown }).ok === false
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ export * from './components.tsx'
2
+ export { PmSchemaForm, schemaFormPropertyNames } from './schema-form/PmSchemaForm.tsx'
3
+ export type { PmSchemaFormProps } from './schema-form/PmSchemaForm.tsx'
4
+ export { StepProductionEditor } from './StepProductionEditor.tsx'
5
+ export type { StepProductionEditorProps } from './StepProductionEditor.tsx'
6
+ export { MediaClipEditor } from './MediaClipEditor.tsx'
7
+ export type { MediaClipAssetChoice, MediaClipDraft, MediaClipEditorProps } from './MediaClipEditor.tsx'
8
+ export type { StepDurationDraft, StepDurationKey, StepTimingKey, StepTimingPatch } from './step-production.ts'
9
+ export { useMessages, useWorkbench, useWorkbenchState, WorkbenchProvider } from './context.tsx'
10
+ export type { DocumentComment } from '@lincyaw/kernel'
11
+ export { localeOf, MESSAGES, timeAgo } from './messages.ts'
12
+ export type { Locale, WorkbenchMessages } from './messages.ts'
13
+ export { findPlaceAtLine, findPlaceSource } from './places.ts'
14
+ export type { PlaceWithSource } from './places.ts'
15
+ export { connectPreview } from './preview.ts'
16
+ export type { PreviewConnection, PreviewHandlers } from './preview.ts'
17
+ export { documentDirFor, hasSlides, isAgentChanging, selectedDocument, Workbench } from './workbench.ts'
18
+ export type { AgentAnswer, AgentRequest, Pointer, PreviewSink, SaveState, WebView, WorkbenchMode, WorkbenchState } from './workbench.ts'
19
+ export { OperationLog } from './telemetry.ts'
20
+ export type { OperationBatch, OperationValue } from './telemetry.ts'
21
+ export { isObservedWorkspaceFailure, workspaceClient, WorkspaceServerError } from './workspace-client.ts'
22
+ export type { ApprovalEvent, WorkspaceCapabilities, WorkspaceClient, WorkspaceRequestEvent, WorkspaceSubscription, WorkspaceTransport, AuthoringClientChannel, AuthoringClientSubscription } from './workspace-client.ts'
23
+ export { DocumentView } from './panels/DocumentView.tsx'
24
+ export type { DocumentAside } from './panels/DocumentView.tsx'
25
+ export { HistoryPanel } from './panels/HistoryPanel.tsx'
26
+ export type { HistoryPanelProps, HistoryRevisionAction } from './panels/HistoryPanel.tsx'
27
+ export { HomePanel } from './panels/HomePanel.tsx'
28
+ export { WorkbenchApprovals } from './approvals.tsx'
29
+ export { assistantMascotAction, useAssistantMascot } from './assistant-mascot.ts'
30
+ export type { AssistantActivity } from './assistant-mascot.ts'
31
+ export { mascotEnabled, setMascotEnabled, useMascotEnabled } from './mascot-preference.ts'
32
+ export { PreviewPanel } from './panels/PreviewPanel.tsx'
33
+ export { SpeechSettingsPanel } from './panels/SpeechSettingsPanel.tsx'
34
+ export { SPEECH_SETTINGS_MESSAGES } from './speech-settings-messages.ts'
35
+ export { connectAuthoringClient } from './authoring-client.ts'
36
+ export { SHORTCUTS } from './shortcuts.ts'
package/src/jobs.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import type { CompileJob, JobSource } from '@lincyaw/kernel'
3
+ import type { WorkbenchStore } from './workbench.ts'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+
6
+ /** Of two copies of a job (an event and an answer cross on the way), the one of the later change. */
7
+ function newer(known: CompileJob | undefined, job: CompileJob): CompileJob {
8
+ return known && known.sequence > job.sequence ? known : job
9
+ }
10
+
11
+ /**
12
+ * The open document's builds and exports: what it can be made into, and its jobs as the workspace server runs them,
13
+ * whoever started them (the author here, or the AI), followed as they change.
14
+ */
15
+ export class DocumentJobs {
16
+ readonly #client: WorkspaceClient
17
+ readonly #store: WorkbenchStore
18
+ #unsubscribe?: () => void
19
+ #scope = 0
20
+ #readTicket = 0
21
+ #baselineChanged = false
22
+
23
+ constructor(client: WorkspaceClient, store: WorkbenchStore) {
24
+ this.#client = client
25
+ this.#store = store
26
+ }
27
+
28
+ subscribe(dir: string): void {
29
+ this.#unsubscribe?.()
30
+ const scope = ++this.#scope
31
+ this.#baselineChanged = false
32
+ const current = () => scope === this.#scope && this.#store.state.document === dir
33
+ this.#unsubscribe = this.#client.subscribeJobs(dir, (job) => {
34
+ if (current()) {
35
+ if (!this.#store.state.jobs) this.#baselineChanged = true
36
+ this.#merge(job)
37
+ }
38
+ }, () => {
39
+ // A failed carrier may have missed changes.
40
+ if (current()) void this.#read(dir, current)
41
+ }, () => {
42
+ // Subscribe before reading: the first list and a reconnect can race a job.
43
+ if (current()) void this.#read(dir, current)
44
+ })
45
+ void this.#read(dir, current)
46
+ }
47
+
48
+ async #read(dir: string, current: () => boolean): Promise<void> {
49
+ const ticket = ++this.#readTicket
50
+ const startedWith = new Map((this.#store.state.jobs?.jobs ?? []).map(job => [job.id, job.sequence]))
51
+ const owns = () => current() && ticket === this.#readTicket
52
+ try {
53
+ const answer = await this.#client.readJobs(dir)
54
+ if (!owns()) return
55
+ // The events that arrived meanwhile may be newer than the list read.
56
+ const known = new Map((this.#store.state.jobs?.jobs ?? []).map(job => [job.id, job]))
57
+ const read = new Set(answer.jobs.map(job => job.id))
58
+ // Keep only observations made after this read began, including a job that already finished.
59
+ const observed = [...known.values()].filter(job => !read.has(job.id) && (!startedWith.has(job.id) || job.sequence > startedWith.get(job.id)!))
60
+ const jobs = [...observed, ...answer.jobs.map(job => newer(known.get(job.id), job))]
61
+ this.#store.set({ jobs: { ...answer, jobs }, jobsFailure: undefined })
62
+ if (this.#baselineChanged && owns()) {
63
+ this.#baselineChanged = false
64
+ void this.#read(dir, current)
65
+ }
66
+ } catch (error) {
67
+ if (owns()) this.#store.set({ jobsFailure: errorMessage(error) })
68
+ }
69
+ }
70
+
71
+ /** A job as it is now, in its place among the document's (newest first): a copy older than the one known is left. */
72
+ #merge(job: CompileJob): void {
73
+ const known = this.#store.state.jobs
74
+ if (!known) return
75
+ const jobs = known.jobs.some(own => own.id === job.id) ? known.jobs.map(own => (own.id === job.id ? newer(own, job) : own)) : [job, ...known.jobs]
76
+ this.#store.set({ jobs: { ...known, jobs } })
77
+ }
78
+
79
+ async start(dir: string, target: string, source: JobSource): Promise<void> {
80
+ const scope = this.#scope
81
+ const current = () => scope === this.#scope && this.#store.state.document === dir
82
+ try {
83
+ const job = await this.#client.startJob(dir, target, source)
84
+ if (!current()) return
85
+ this.#merge(job)
86
+ this.#store.set({ jobsFailure: undefined })
87
+ } catch (error) {
88
+ if (current()) this.#store.set({ jobsFailure: errorMessage(error) })
89
+ }
90
+ }
91
+
92
+ async cancel(id: string): Promise<void> {
93
+ const dir = this.#store.state.document
94
+ if (dir === undefined) return
95
+ const scope = this.#scope
96
+ const current = () => scope === this.#scope && this.#store.state.document === dir
97
+ try {
98
+ const job = await this.#client.cancelJob(dir, id)
99
+ if (current()) this.#merge(job)
100
+ } catch (error) {
101
+ if (current()) this.#store.set({ jobsFailure: errorMessage(error) })
102
+ }
103
+ }
104
+
105
+ stop(): void {
106
+ this.#scope++
107
+ this.#baselineChanged = false
108
+ this.#unsubscribe?.()
109
+ this.#unsubscribe = undefined
110
+ }
111
+ }
@@ -0,0 +1,37 @@
1
+ // Whether the author wants the kitten: one choice for every place it shows, kept in this browser and followed by
2
+ // every open tab. It is on until the author turns it off.
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ const KEY = 'presmium.mascot'
6
+ const listeners = new Set<() => void>()
7
+
8
+ function read(): boolean {
9
+ try { return localStorage.getItem(KEY) !== 'off' } catch { return true }
10
+ }
11
+
12
+ let enabled = typeof localStorage === 'undefined' ? true : read()
13
+
14
+ function subscribe(listener: () => void): () => void {
15
+ listeners.add(listener)
16
+ const storage = (event: StorageEvent) => {
17
+ if (event.key !== KEY) return
18
+ enabled = read()
19
+ for (const each of listeners) each()
20
+ }
21
+ window.addEventListener('storage', storage)
22
+ return () => { listeners.delete(listener); window.removeEventListener('storage', storage) }
23
+ }
24
+
25
+ export function mascotEnabled(): boolean {
26
+ return enabled
27
+ }
28
+
29
+ export function setMascotEnabled(on: boolean): void {
30
+ enabled = on
31
+ try { localStorage.setItem(KEY, on ? 'on' : 'off') } catch { /* The choice holds for this page only. */ }
32
+ for (const listener of listeners) listener()
33
+ }
34
+
35
+ export function useMascotEnabled(): boolean {
36
+ return useSyncExternalStore(subscribe, mascotEnabled, () => true)
37
+ }
@@ -0,0 +1,83 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+ import type { MediaClip } from '@lincyaw/kernel'
3
+ import type { MediaClipDraft } from './MediaClipEditor.tsx'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+
6
+ export interface MediaDrafts { selectedClipId?: string, entries: Record<string, MediaClipDraft> }
7
+ interface Entry { value: MediaDrafts, failed: boolean, volatile: boolean, unreadable?: string }
8
+ const memory = new WeakMap<WorkspaceClient, Map<string, Entry>>()
9
+ const prefix = 'presmium.media-drafts.v1:', limit = 64
10
+ const keyOf = (identity: string, stepId: string) => prefix + JSON.stringify([identity, stepId])
11
+ const string = (value: unknown): value is string => typeof value === 'string'
12
+ const number = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
13
+
14
+ function validClip(value: unknown): value is MediaClip {
15
+ return isRecord(value) && string(value.id) && string(value.assetId) && isRecord(value.sourceRange) && number(value.sourceRange.startMs) && number(value.sourceRange.endMs)
16
+ && isRecord(value.attachment) && string(value.attachment.stepId) && number(value.attachment.offsetMs) && number(value.volume) && value.volume <= 1 && (value.muted === undefined || typeof value.muted === 'boolean')
17
+ }
18
+ function valid(value: unknown): value is MediaDrafts {
19
+ return isRecord(value) && (value.selectedClipId === undefined || string(value.selectedClipId)) && isRecord(value.entries)
20
+ && Object.entries(value.entries).every(([key, draft]) => string(key) && isRecord(draft) && string(draft.version) && validClip(draft.clip) && (draft.baseClip === undefined || validClip(draft.baseClip))
21
+ && [draft.startText, draft.endText, draft.offsetText].every(text => text === undefined || string(text)))
22
+ }
23
+ function store(client: WorkspaceClient): Map<string, Entry> {
24
+ let known = memory.get(client)
25
+ if (!known) memory.set(client, known = new Map())
26
+ return known
27
+ }
28
+ function trim(known: Map<string, Entry>): void {
29
+ // Persisted forms can be reloaded; failed writes must keep their only current copy.
30
+ for (const [key, value] of known) {
31
+ if (known.size <= limit) break
32
+ if (!value.volatile) known.delete(key)
33
+ }
34
+ }
35
+ function load(client: WorkspaceClient, key: string): Entry {
36
+ const known = store(client), previous = known.get(key)
37
+ if (previous) { known.delete(key); known.set(key, previous); return previous }
38
+ const entry: Entry = { value: { entries: {} }, failed: false, volatile: false }
39
+ try {
40
+ const raw = sessionStorage.getItem(key)
41
+ if (raw !== null) {
42
+ entry.unreadable = raw
43
+ if (raw.length > 131072) throw new Error('media-drafts-size')
44
+ const value: unknown = JSON.parse(raw)
45
+ if (!valid(value)) throw new Error('media-drafts-shape')
46
+ entry.value = value; delete entry.unreadable
47
+ }
48
+ } catch { entry.failed = true }
49
+ known.set(key, entry)
50
+ trim(known)
51
+ return entry
52
+ }
53
+
54
+ /** Only form input is cached. Current canonical IDs, versions, media and step limits are checked on apply. */
55
+ export function readMediaDrafts(client: WorkspaceClient, identity: string, stepId: string): MediaDrafts {
56
+ return structuredClone(load(client, keyOf(identity, stepId)).value)
57
+ }
58
+ export function mediaDraftStorageFailed(client: WorkspaceClient, identity: string, stepId: string): boolean {
59
+ return load(client, keyOf(identity, stepId)).failed
60
+ }
61
+ export function keepMediaDrafts(client: WorkspaceClient, identity: string, stepId: string, value: MediaDrafts): boolean {
62
+ if (!valid(value)) return false
63
+ const key = keyOf(identity, stepId), entry = load(client, key)
64
+ entry.value = structuredClone(value); entry.volatile = true
65
+ const known = store(client); known.set(key, entry)
66
+ try {
67
+ const raw = JSON.stringify(value)
68
+ if (raw.length > 131072) throw new Error('media-drafts-size')
69
+ if (entry.unreadable !== undefined) sessionStorage.setItem(`${key}:unreadable`, entry.unreadable)
70
+ sessionStorage.setItem(key, raw)
71
+ const keys = Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index)!).filter(name => name.startsWith(prefix) && !name.endsWith(':unreadable') && name !== key)
72
+ let excess = keys.length - limit + 1
73
+ for (const old of keys) {
74
+ if (excess <= 0) break
75
+ let previous: unknown
76
+ try { previous = JSON.parse(sessionStorage.getItem(old) ?? 'null') } catch { continue }
77
+ if (valid(previous) && !Object.keys(previous.entries).length && sessionStorage.getItem(`${old}:unreadable`) === null) { sessionStorage.removeItem(old); excess-- }
78
+ }
79
+ entry.failed = false; entry.volatile = false; delete entry.unreadable
80
+ trim(known)
81
+ return true
82
+ } catch { entry.failed = true; trim(known); return false }
83
+ }
@@ -0,0 +1,53 @@
1
+ import type { Locale } from './messages.ts'
2
+
3
+ export const MEDIA_MESSAGES = {
4
+ 'zh-CN': {
5
+ title: '音频片段', import: '导入音频', retryImport: '重试导入', asset: '音频素材', chooseAsset: '选择音频素材', clip: '当前步骤片段', newClip: '新片段',
6
+ in: '入点', out: '出点', timeFormat: 'mm:ss.mmm', offset: '步骤内延后(秒)', volume: '音量', mute: '静音', unmute: '取消静音',
7
+ add: '采用到本步', apply: '应用', restore: '使用完整音频', remove: '删除片段', check: '核对当前版本', retry: '重新加载',
8
+ loading: '正在加载音频…', pending: '尚未采用', saved: '片段已应用到当前步骤', imported: '音频已导入',
9
+ original: (duration: string, used?: string) => `原始 00:00.000 – ${duration}${used ? ` · 使用 ${used}` : ''}`,
10
+ clipLabel: (index: number, name: string) => `片段 ${index + 1} · ${name}`,
11
+ invalidTime: '请输入 mm:ss.mmm 格式的时间。', invalidRange: '出点须晚于入点,且不超出原始时长。', invalidOffset: '请输入不小于 0 的秒数。',
12
+ exceedsStep: '片段超出本步。请裁剪片段、减少延后,或另行调整本步的停留。',
13
+ unavailable: '先准备当前版本并确认本步时段,再采用片段。',
14
+ review: '文稿已变化,输入仍保留;请核对当前版本后采用。', removed: '原片段已移除,输入仍保留;请选择新片段。',
15
+ storageFailed: '无法在本机保留草稿,请保持页面打开。', failed: '无法完成音频操作。输入已保留,请重试。',
16
+ sourceChanged: '文稿已变化,输入仍保留。请准备当前版本后重试。',
17
+ size: '请选择非空且不超过 16 MiB 的音频。', format: '此文件不是受支持的单音轨音频,或数据已损坏。',
18
+ duration: '无法确认音频时长,或时长超过 10 分钟。', missing: '找不到音频素材或原始文件,请检查或重新导入。',
19
+ changed: '原始音频已变化,请重新加载并准备当前版本。', serviceUnavailable: '当前工作区未提供音频导入服务。',
20
+ },
21
+ en: {
22
+ title: 'Audio Clip', import: 'Import Audio', retryImport: 'Retry Import', asset: 'Audio asset', chooseAsset: 'Choose Audio', clip: 'Clips in this step', newClip: 'New Clip',
23
+ in: 'In', out: 'Out', timeFormat: 'mm:ss.mmm', offset: 'Step offset (seconds)', volume: 'Volume', mute: 'Mute', unmute: 'Unmute',
24
+ add: 'Add to Step', apply: 'Apply', restore: 'Use Full Audio', remove: 'Delete Clip', check: 'Review Current Version', retry: 'Reload',
25
+ loading: 'Loading audio…', pending: 'Not applied', saved: 'Clip applied to this step', imported: 'Audio imported',
26
+ original: (duration: string, used?: string) => `Original 00:00.000 – ${duration}${used ? ` · Used ${used}` : ''}`,
27
+ clipLabel: (index: number, name: string) => `Clip ${index + 1} · ${name}`,
28
+ invalidTime: 'Enter a time in mm:ss.mmm format.', invalidRange: 'The out point must follow the in point and stay within the original duration.', invalidOffset: 'Enter an offset of 0 seconds or more.',
29
+ exceedsStep: 'The clip exceeds this step. Trim it, reduce its offset, or change the step hold separately.',
30
+ unavailable: 'Prepare the current version and confirm this step’s timing before applying.',
31
+ review: 'The document changed. Your input is kept; review the current version before applying.', removed: 'The original clip was removed. Your input is kept; choose New Clip.',
32
+ storageFailed: 'Couldn’t keep the draft on this device. Keep this page open.', failed: 'Couldn’t finish the audio operation. Your input is kept; try again.',
33
+ sourceChanged: 'The document changed. Your input is kept; prepare the current version and retry.',
34
+ size: 'Choose nonempty audio of at most 16 MiB.', format: 'The file is not supported single-track audio, or its data is damaged.',
35
+ duration: 'The audio duration is unconfirmed or exceeds ten minutes.', missing: 'The audio asset or original file is missing. Check it or import again.',
36
+ changed: 'The original audio changed. Reload it and prepare the current version.', serviceUnavailable: 'This workspace does not provide audio import.',
37
+ },
38
+ }
39
+
40
+ export function mediaFailure(error: unknown, locale: Locale): string {
41
+ const code = error instanceof Error ? error.message : String(error), t = MEDIA_MESSAGES[locale]
42
+ if (['document-version-changed', 'production-revision-changed', 'production-binding-changed', 'production-plan-changed', 'production-source-changed', 'document-context-changed'].includes(code)) return t.sourceChanged
43
+ if (code === 'production.asset-size') return t.size
44
+ if (code === 'production.asset-duration') return t.duration
45
+ if (code === 'production.asset-changed' || code === 'production.asset-fingerprint') return t.changed
46
+ if (['production.asset-missing', 'production.asset-path', 'production.asset-file', 'workspace-file-missing'].includes(code)) return t.missing
47
+ if (code === 'production.asset-format' || code === 'production.asset-kind') return t.format
48
+ if (code === 'production.media-range') return t.invalidRange
49
+ if (code === 'production.media-step-range') return t.exceedsStep
50
+ if (code === 'production.media-step-unknown') return t.unavailable
51
+ if (code === 'production-audio-unavailable') return t.serviceUnavailable
52
+ return t.failed
53
+ }
@@ -0,0 +1,12 @@
1
+ /** Untouched values keep their original numeric precision; edited values use the shared media grammar. */
2
+ export function mediaTime(value: number): string {
3
+ const ms = Math.max(0, Math.round(value))
4
+ return `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${(ms % 60000 / 1000).toFixed(3).padStart(6, '0')}`
5
+ }
6
+ export function mediaTimeValue(raw: string | undefined, original: number): number | undefined {
7
+ if (raw === undefined || raw === mediaTime(original)) return original
8
+ const match = /^(\d+):([0-5]\d)(?:\.(\d{1,6}))?$/.exec(raw.normalize('NFKC').trim())
9
+ if (!match) return undefined
10
+ const value = Number(match[1]) * 60000 + Number(match[2]) * 1000 + Number(`0.${match[3] ?? '0'}`) * 1000
11
+ return Number.isFinite(value) ? value : undefined
12
+ }