@lincyaw/workbench 0.1.0 → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
|
@@ -0,0 +1,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
|
+
}
|
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
CHANGED
|
@@ -1,46 +1,36 @@
|
|
|
1
|
-
import type { IconName } from '@lincyaw/design'
|
|
2
|
-
import type { ComponentType } from 'react'
|
|
3
|
-
import type { WorkbenchMessages } from './messages.ts'
|
|
4
|
-
import { ChangesPanel } from './panels/Changes.tsx'
|
|
5
|
-
import { DocumentView } from './panels/DocumentView.tsx'
|
|
6
|
-
import { FilesPanel } from './panels/Files.tsx'
|
|
7
|
-
import { HomePanel } from './panels/Home.tsx'
|
|
8
|
-
import { PreviewPanel } from './panels/Preview.tsx'
|
|
9
|
-
import { ProblemsPanel } from './panels/Problems.tsx'
|
|
10
|
-
import { SourcePanel } from './panels/Source.tsx'
|
|
11
|
-
|
|
12
|
-
export type * from './api.ts'
|
|
13
1
|
export * from './components.tsx'
|
|
14
|
-
export {
|
|
15
|
-
export type {
|
|
16
|
-
export {
|
|
17
|
-
export type {
|
|
18
|
-
export {
|
|
19
|
-
export type {
|
|
20
|
-
export {
|
|
21
|
-
export
|
|
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'
|
|
22
15
|
export { connectPreview } from './preview.ts'
|
|
23
16
|
export type { PreviewConnection, PreviewHandlers } from './preview.ts'
|
|
24
|
-
export {
|
|
25
|
-
export {
|
|
26
|
-
export
|
|
27
|
-
export {
|
|
28
|
-
export
|
|
29
|
-
export {
|
|
30
|
-
export {
|
|
31
|
-
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
preview: { id: 'preview', icon: 'slides', title: t => t.preview, Component: PreviewPanel },
|
|
45
|
-
changes: { id: 'changes', icon: 'diff', title: t => t.changes, Component: ChangesPanel },
|
|
46
|
-
} satisfies Record<string, PanelDescriptor>
|
|
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
|
+
}
|