@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,136 @@
1
+ import { authoringClientResultFits } from '@lincyaw/kernel'
2
+ import type { AuthoringClientActionResult, AuthoringClientEvent, AuthoringClientSnapshot, CanonicalSource } from '@lincyaw/kernel'
3
+ import type { Workbench } from './workbench.ts'
4
+ import type { AuthoringClientChannel } from './workspace-client.ts'
5
+
6
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
7
+ // Clock samples are observations, not user input revisions. Never push at animation-frame frequency.
8
+ const signature = (value: AuthoringClientSnapshot) => JSON.stringify({ ...value, revision: 0, playback: value.playback && { ...value.playback,
9
+ position: value.playback.position && { ...value.playback.position, timeMs: 0, stepTimeMs: 0 } } })
10
+ type Attachment = { directory: string, identity: string, epoch: string, channel: AuthoringClientChannel, connectionId?: string }
11
+
12
+ /** One logical reader on Studio's shared authenticated socket; no extra browser SSE or polling timer. */
13
+ export function connectAuthoringClient(workbench: Workbench): () => void {
14
+ const open = workbench.client.openAuthoringClient
15
+ if (!open) return () => {}
16
+ const clientId = crypto.randomUUID()
17
+ let closed = false, revision = 0, sequence = 0
18
+ let attached: Attachment | undefined
19
+ let lastSignature: string | undefined, lastContent: object | undefined
20
+ let lastDraftRevision: string | undefined
21
+ let active: { requestId: string, controller: AbortController } | undefined
22
+ let reports: Promise<void> = Promise.resolve()
23
+ let pendingState: { owner: Attachment, connectionId: string, value: AuthoringClientSnapshot } | undefined
24
+ let stateSending = false
25
+ const seen = new Set<string>()
26
+ const snapshot = (): AuthoringClientSnapshot | undefined => {
27
+ const value = workbench.authoringClientSnapshot(revision)
28
+ if (!value) return
29
+ const next = signature(value), content = workbench.state.authoring?.content, drafts = workbench.authoringDraftRevision()
30
+ if (next !== lastSignature || content !== lastContent || drafts !== lastDraftRevision) { revision++; lastSignature = next; lastContent = content; lastDraftRevision = drafts }
31
+ return { ...value, revision }
32
+ }
33
+ const detach = () => {
34
+ active?.controller.abort(); active = undefined
35
+ attached?.channel.close(); attached = undefined
36
+ pendingState = undefined
37
+ lastSignature = undefined; lastContent = undefined; lastDraftRevision = undefined; seen.clear()
38
+ }
39
+ const send = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot, acknowledgement?: { requestId: string, state: 'executing' | 'done' | 'refused' | 'failed', error?: { code: string }, result?: AuthoringClientActionResult }, preflight?: () => void) => {
40
+ const report = { sequence: ++sequence, snapshot: value, ...acknowledgement }
41
+ const next = reports.then(() => {
42
+ if (closed || attached !== owner || owner.connectionId !== connectionId) throw new Error('client-connection-changed')
43
+ preflight?.()
44
+ return acknowledgement ? owner.channel.acknowledge(connectionId, report as import('@lincyaw/kernel').AuthoringClientAcknowledgement) : owner.channel.publish(connectionId, report)
45
+ })
46
+ reports = next.catch(() => {})
47
+ return next
48
+ }
49
+ const publishState = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot) => {
50
+ pendingState = { owner, connectionId, value }
51
+ if (stateSending) return
52
+ stateSending = true
53
+ void (async () => {
54
+ try {
55
+ while (pendingState) {
56
+ const pending = pendingState; pendingState = undefined
57
+ if (attached !== pending.owner || attached.connectionId !== pending.connectionId) continue
58
+ // Coalesce rapid input while a publication is in flight; never retain an unbounded keystroke queue.
59
+ const latest = snapshot() ?? pending.value
60
+ await send(pending.owner, pending.connectionId, latest).catch(() => {})
61
+ }
62
+ } finally { stateSending = false }
63
+ })()
64
+ }
65
+ const received = async (owner: Attachment, event: AuthoringClientEvent) => {
66
+ if (closed || attached !== owner) return
67
+ if (event.type === 'connected') {
68
+ active?.controller.abort(); active = undefined
69
+ owner.connectionId = event.connectionId
70
+ const value = snapshot()
71
+ if (value) publishState(owner, event.connectionId, value)
72
+ return
73
+ }
74
+ if (event.type === 'cancel') { if (event.connectionId === owner.connectionId && event.requestId === active?.requestId) active.controller.abort(); return }
75
+ const request = event.request, connectionId = owner.connectionId
76
+ if (!connectionId || request.clientId !== clientId || request.epoch !== owner.epoch || request.expectedClientState.connectionId !== connectionId || seen.has(request.requestId)) return
77
+ seen.add(request.requestId)
78
+ if (seen.size > 64) seen.delete(seen.values().next().value!)
79
+ const value = snapshot()
80
+ if (!value) return
81
+ const controller = new AbortController(), task = { requestId: request.requestId, controller }
82
+ const owned = () => {
83
+ controller.signal.throwIfAborted()
84
+ if (closed || attached !== owner || owner.connectionId !== connectionId || active !== task || Date.now() >= Date.parse(event.expiresAt)) throw new Error('client-request-expired')
85
+ }
86
+ if (active || value.revision !== request.expectedClientState.revision || !sameSource(value.source, request.source) || !value.actions.includes(request.action.type) || Date.now() >= Date.parse(event.expiresAt)) {
87
+ await send(owner, connectionId, value, { requestId: request.requestId, state: 'refused', error: { code: 'client-state-changed' } }).catch(() => {})
88
+ return
89
+ }
90
+ active = task
91
+ const expiresAt = Date.parse(event.expiresAt)
92
+ const timeout = setTimeout(() => controller.abort(new Error('client-request-expired')), Math.max(0, expiresAt - Date.now()))
93
+ try {
94
+ // Only a confirmed executing acknowledgement grants this epoch permission to invoke a control.
95
+ await send(owner, connectionId, value, { requestId: request.requestId, state: 'executing' })
96
+ owned()
97
+ const latest = snapshot()
98
+ if (!latest || latest.revision !== request.expectedClientState.revision || !sameSource(latest.source, request.source)) throw new Error('client-state-changed')
99
+ const fits = (result: AuthoringClientActionResult) => authoringClientResultFits({ request, connection: { clientId, epoch: owner.epoch, connectionId }, document: owner.directory, snapshot: snapshot() ?? value }, result)
100
+ const actionResult = await workbench.executeAuthoringClientAction(request.action, request.source, controller.signal, expiresAt, fits)
101
+ owned()
102
+ const result = snapshot()
103
+ if (!result) throw new Error('client-document-changed')
104
+ await send(owner, connectionId, result, { requestId: request.requestId, state: 'done', ...actionResult ? { result: actionResult } : {} }, actionResult ? () => {
105
+ owned()
106
+ const latest = snapshot()
107
+ if (!latest || latest.revision !== result.revision || !['component-drafts.restore', 'component-drafts.discard', 'comment-drafts.restore', 'comment-drafts.discard'].includes(actionResult.type) && latest.revision !== request.expectedClientState.revision) throw new Error(actionResult.family === 'component' ? 'client-component-drafts-changed' : 'client-comment-drafts-changed')
108
+ if (!fits(actionResult)) throw new Error(actionResult.family === 'component' ? 'client-component-result-too-large' : 'client-comment-result-too-large')
109
+ } : undefined)
110
+ } catch (error) {
111
+ const result = snapshot()
112
+ const code = error instanceof Error && /^(client|production)-[a-z-]{1,100}$/.test(error.message) ? error.message : 'client-action-not-verified'
113
+ if (!controller.signal.aborted && attached === owner && owner.connectionId === connectionId && result) await send(owner, connectionId, result, { requestId: request.requestId, state: 'failed', error: { code } }).catch(() => {})
114
+ } finally { clearTimeout(timeout); if (active === task) active = undefined }
115
+ }
116
+ const changed = () => {
117
+ const state = workbench.state, source = state.authoring
118
+ if (closed || state.document === undefined || !source?.identity || source.loading) { detach(); return }
119
+ const before = revision, value = snapshot()
120
+ if (!value) { detach(); return }
121
+ if (attached && (attached.directory !== state.document || attached.identity !== source.identity)) detach()
122
+ if (!attached) {
123
+ const epoch = crypto.randomUUID()
124
+ // Callbacks run after the channel has been installed; the socket implementation is asynchronous.
125
+ const owner = { directory: state.document, identity: source.identity, epoch } as Attachment
126
+ owner.channel = open(state.document, { clientId, epoch, snapshot: value }, event => { void received(owner, event) }, () => {
127
+ if (attached === owner) { owner.connectionId = undefined; active?.controller.abort(); active = undefined }
128
+ })
129
+ attached = owner
130
+ } else if (before !== revision && attached.connectionId) publishState(attached, attached.connectionId, value)
131
+ }
132
+ const stop = workbench.subscribe(changed)
133
+ const stopDrafts = workbench.subscribeAuthoringDraftChanges(changed)
134
+ changed()
135
+ return () => { closed = true; stop(); stopDrafts(); detach() }
136
+ }
@@ -0,0 +1,28 @@
1
+ import type { AuthoringClientActionResult } from '@lincyaw/kernel'
2
+
3
+ type Fragment = Extract<AuthoringClientActionResult, { jsonText: string }>
4
+ type Fields = 'offset' | 'endOffset' | 'totalUtf16' | 'totalUtf8' | 'sha256' | 'jsonText'
5
+ type FragmentBase = Fragment extends infer Value ? Value extends Fragment ? Omit<Value, Fields> : never : never
6
+ export const draftContentHash = async (text: string): Promise<string> => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map(value => value.toString(16).padStart(2, '0')).join('')
7
+ const boundary = (text: string, offset: number) => !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
8
+
9
+ /** Budget the complete client receipt and WS frame, preserving exact JSON text and Unicode progress. */
10
+ export async function authoringDraftFragment(options: { text: string, base: FragmentBase, offset: number, maxChars?: number }, current: () => void, fits: (value: AuthoringClientActionResult) => boolean): Promise<Fragment> {
11
+ const { text, base, offset } = options, maxChars = options.maxChars ?? 2048
12
+ const sha256 = await draftContentHash(text)
13
+ current()
14
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !boundary(text, offset)) throw new Error(`client-${base.family}-offset-invalid`)
15
+ if (!Number.isSafeInteger(maxChars) || maxChars < 2 || maxChars > 2048) throw new Error(`client-${base.family}-fragment-limit`)
16
+ const lengths = { offset, totalUtf16: text.length, totalUtf8: new TextEncoder().encode(text).byteLength, sha256 }
17
+ let low = offset, high = Math.min(text.length, offset + maxChars), best: Fragment | undefined
18
+ while (low <= high) {
19
+ const middle = Math.floor((low + high) / 2)
20
+ const end = boundary(text, middle) ? middle : middle - 1
21
+ const result: Fragment = { ...base, ...lengths, endOffset: end, jsonText: text.slice(offset, end) }
22
+ if (fits(result)) { if (end > offset || end === text.length) best = result; low = middle + 1 }
23
+ else high = middle - 1
24
+ }
25
+ if (!best) throw new Error(`client-${base.family}-fragment-too-large`)
26
+ current()
27
+ return best
28
+ }
@@ -0,0 +1,47 @@
1
+ import { ICONS } from '@lincyaw/design'
2
+ import type { IconName } from '@lincyaw/design'
3
+ import * as Tooltip from '@radix-ui/react-tooltip'
4
+ import { createElement, forwardRef } from 'react'
5
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
6
+
7
+ const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
8
+
9
+ export function PmIcon({ name, size: _size, strokeWidth = 1.7 }: { name: IconName, size?: number | string, strokeWidth?: number }) {
10
+ return (
11
+ <svg className="pm-ui-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
12
+ {ICONS[name].map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
13
+ </svg>
14
+ )
15
+ }
16
+
17
+ export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
18
+
19
+ export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & { type?: 'button' | 'submit' | 'reset' }
20
+ export type ControlDensity = 'compact' | 'regular' | 'touch'
21
+ export type ControlProps = { density?: ControlDensity, loading?: boolean }
22
+
23
+ export const PmButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { variant?: ButtonVariant, small?: boolean, icon?: IconName }>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, type = 'button', className, children, disabled, ...rest }, ref) {
24
+ return (
25
+ <button {...rest} ref={ref} type={type} data-density={density} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className)}>
26
+ <span className={cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text')}>{(icon || loading !== undefined) && <span aria-hidden="true" className={cls('pm-ui-button__icon', loading && 'pm-ui-loading')}>{loading ? <PmIcon name="loading" /> : icon && <PmIcon name={icon} />}</span>}{children}</span>
27
+ </button>
28
+ )
29
+ })
30
+
31
+ /** Radix opens on keyboard focus immediately; pointer hover uses the I11 delay. */
32
+ export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
33
+ return (
34
+ <Tooltip.Provider delayDuration={300} skipDelayDuration={0}>
35
+ <Tooltip.Root><Tooltip.Trigger asChild>{children}</Tooltip.Trigger><Tooltip.Portal><div className="pm-ui-tooltip-layer"><Tooltip.Content className="pm-ui-tooltip pm-ui" side="bottom" sideOffset={6} collisionPadding={8}>
36
+ {label}{shortcut && <kbd className="pm-ui-shortcut">{shortcut}</kbd>}
37
+ </Tooltip.Content></div></Tooltip.Portal></Tooltip.Root>
38
+ </Tooltip.Provider>
39
+ )
40
+ }
41
+
42
+ export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { icon: IconName, label: string, hint?: string, shortcut?: string, small?: boolean, tooltip?: boolean }>(function PmIconButton({ icon, label, hint, shortcut, small, tooltip = true, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
43
+ const button = <button {...rest} ref={ref} type="button" data-density={density} aria-label={label} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-icon-button', className)}>
44
+ <span className={cls('pm-ui-button__content', loading && 'pm-ui-loading')}><PmIcon name={loading ? 'loading' : icon} /></span>
45
+ </button>
46
+ return tooltip ? <PmTooltip label={hint ?? label} shortcut={shortcut}>{button}</PmTooltip> : button
47
+ })
@@ -0,0 +1,61 @@
1
+ import type { CaptionTimingSource, TeachingDocument } from '@lincyaw/kernel'
2
+
3
+ export interface CaptionTimingRow { id: string, from: number, to: number, start: string, end: string }
4
+ export interface CaptionTimingDraft { version: string, text: string, basis: CaptionTimingSource, rows: CaptionTimingRow[] }
5
+ type Scope = { identity: string, memberId: string }
6
+ interface Entry { values: Map<string, CaptionTimingDraft>, failed: boolean, unreadable: boolean }
7
+ const MAX_CHARS = 1 << 20
8
+ const MAX_TARGETS = 512
9
+ const valid = (value: unknown): value is CaptionTimingDraft => {
10
+ if (!value || typeof value !== 'object') return false
11
+ const d = value as CaptionTimingDraft, b = d.basis
12
+ if (typeof d.version !== 'string' || !d.version || d.version.length > 256 || typeof d.text !== 'string' || d.text.length > MAX_CHARS
13
+ || !b || typeof b !== 'object' || typeof b.available !== 'boolean' || typeof b.sourceFingerprint !== 'string'
14
+ || b.sourceFingerprint.length > 256 || b.playbackFingerprint !== undefined && (typeof b.playbackFingerprint !== 'string' || b.playbackFingerprint.length > 256)
15
+ || b.durationMs !== undefined && (!Number.isSafeInteger(b.durationMs) || b.durationMs < 0)
16
+ || b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > 256)
17
+ || !Array.isArray(b.narrationIds) || b.narrationIds.length > MAX_TARGETS || b.narrationIds.some(id => typeof id !== 'string' || id.length > 256)
18
+ || !Array.isArray(d.rows) || d.rows.length > MAX_TARGETS) return false
19
+ return d.rows.every(r => r && typeof r.id === 'string' && r.id.length <= 256 && Number.isSafeInteger(r.from) && r.from >= 0
20
+ && Number.isSafeInteger(r.to) && r.to >= r.from && r.to <= d.text.length && typeof r.start === 'string' && r.start.length <= 32 && typeof r.end === 'string' && r.end.length <= 32)
21
+ }
22
+
23
+ /** Unapplied author inputs are per-tab recovery data, never the canonical production timing. */
24
+ export class CaptionTimingDrafts {
25
+ readonly #entries = new Map<string, Entry>()
26
+ readonly #leave = (event: BeforeUnloadEvent) => { if ([...this.#entries.values()].some(entry => entry.values.size || entry.failed)) event.preventDefault() }
27
+ constructor() { if (typeof window !== 'undefined') window.addEventListener('beforeunload', this.#leave) }
28
+ #key(scope: Scope): string { return `presmium.caption-timing.v1:${JSON.stringify([scope.identity, scope.memberId])}` }
29
+ #entry(scope: Scope): Entry {
30
+ const key = this.#key(scope), existing = this.#entries.get(key)
31
+ if (existing) return existing
32
+ const result: Entry = { values: new Map(), failed: false, unreadable: false }; this.#entries.set(key, result)
33
+ try {
34
+ const raw = sessionStorage.getItem(key)
35
+ if (raw === null) return result
36
+ if (raw.length > MAX_CHARS) throw new Error('caption-draft-limit')
37
+ const data: unknown = JSON.parse(raw)
38
+ if (!Array.isArray(data) || data.length > MAX_TARGETS) throw new Error('caption-draft-data')
39
+ for (const pair of data) {
40
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || !pair[0] || pair[0].length > 256 || !valid(pair[1]) || result.values.has(pair[0])) throw new Error('caption-draft-data')
41
+ result.values.set(pair[0], pair[1])
42
+ }
43
+ } catch { result.values.clear(); result.failed = true; result.unreadable = true }
44
+ return result
45
+ }
46
+ get(scope: Scope, stepId: string): CaptionTimingDraft | undefined { return this.#entry(scope).values.get(stepId) }
47
+ steps(scope: Scope, document: TeachingDocument): string[] { return [...this.#entry(scope).values.keys()].filter(id => Object.hasOwn(document.steps, id)) }
48
+ failed(scope: Scope): boolean { return this.#entry(scope).failed }
49
+ set(scope: Scope, stepId: string, value: CaptionTimingDraft | undefined): void {
50
+ const entry = this.#entry(scope)
51
+ if (value) entry.values.set(stepId, value); else entry.values.delete(stepId)
52
+ if (entry.unreadable) { entry.failed = true; return }
53
+ try {
54
+ const key = this.#key(scope), raw = JSON.stringify([...entry.values])
55
+ if (raw.length > MAX_CHARS || entry.values.size > MAX_TARGETS || [...entry.values.values()].some(draft => !valid(draft))) throw new Error('caption-draft-limit')
56
+ if (entry.values.size) sessionStorage.setItem(key, raw); else sessionStorage.removeItem(key)
57
+ entry.failed = false
58
+ } catch { entry.failed = true }
59
+ }
60
+ close(): void { if (typeof window !== 'undefined') window.removeEventListener('beforeunload', this.#leave) }
61
+ }
@@ -0,0 +1,14 @@
1
+ export const CAPTION_TIMING_MESSAGES = {
2
+ 'zh-CN': {
3
+ title: '手动字幕时间', open: '设置字幕时间', cancel: '取消', done: '完成', apply: '应用', automatic: '使用自动时间', split: '按换行分段',
4
+ start: '开始(秒)', end: '结束(秒)', step: '本步骤 · 时间从步骤起点计算', invalid: '时间须递增且不超出本步骤。',
5
+ loading: '正在加载字幕时间…', prepare: '请先准备当前步骤。', unavailable: '当前步骤的字幕时间不可用。', changed: '讲稿或音频已变化。', recheck: '再次检查', saved: '字幕时间已保存到当前步骤。',
6
+ failed: '无法确认字幕时间已保存。输入已保留,请重试。', storageFailed: '无法备份字幕时间,请保持此页面打开。', discard: '放弃修改', limit: '本次最多设置 32 段。', segment: (n: number) => `字幕 ${n}`,
7
+ },
8
+ en: {
9
+ title: 'Manual Caption Timing', open: 'Set Caption Times', cancel: 'Cancel', done: 'Done', apply: 'Apply', automatic: 'Use Automatic Times', split: 'Split at Line Breaks',
10
+ start: 'Start (seconds)', end: 'End (seconds)', step: 'Current step · Times start at the step boundary', invalid: 'Times must increase and fit inside this step.',
11
+ loading: 'Loading caption times…', prepare: 'Prepare the current step first.', unavailable: 'Caption timing is unavailable for this step.', changed: 'The script or audio has changed.', recheck: 'Check Again', saved: 'Caption times saved to this step.',
12
+ failed: 'Couldn’t confirm that caption times were saved. Your input is kept; try again.', storageFailed: 'Couldn’t back up caption times. Keep this page open.', discard: 'Discard Changes', limit: 'Set no more than 32 segments at once.', segment: (n: number) => `Caption ${n}`,
13
+ },
14
+ }
@@ -0,0 +1,20 @@
1
+ .pmw-caption-times {
2
+ display: grid;
3
+ gap: var(--pm-space-3);
4
+ min-width: 0;
5
+ }
6
+ .pmw-caption-times__text {
7
+ margin: 0;
8
+ white-space: pre-wrap;
9
+ overflow-wrap: anywhere;
10
+ }
11
+ .pmw-caption-times__range {
12
+ display: grid;
13
+ grid-template-columns: repeat(2, minmax(0, 1fr));
14
+ gap: var(--pm-space-2);
15
+ }
16
+ .pmw-caption-times__actions {
17
+ display: flex;
18
+ flex-wrap: wrap;
19
+ gap: var(--pm-space-2);
20
+ }
@@ -0,0 +1,14 @@
1
+ import { applyDocumentCaptionTiming, captionSourceFingerprint } from '@lincyaw/kernel'
2
+ import type { DocumentAnswer, DocumentCaptionTimingAction, TeachingDocument } from '@lincyaw/kernel'
3
+
4
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
5
+ /** The source actor adopts only this caption slot; later native body input remains owned by the editor. */
6
+ export function reconcileCaptionTiming(action: DocumentCaptionTimingAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
7
+ try {
8
+ if (before.id !== answer.content.id || before.id !== current.id || !same(applyDocumentCaptionTiming(before, action).content, answer.content)) return
9
+ if (!current.steps[action.stepId]) return current
10
+ if (!same(before.production.captionTimings?.[action.stepId], current.production.captionTimings?.[action.stepId])
11
+ || captionSourceFingerprint(before, action.stepId) !== captionSourceFingerprint(current, action.stepId)) return
12
+ return applyDocumentCaptionTiming(current, action).content
13
+ } catch { return }
14
+ }
@@ -0,0 +1,54 @@
1
+ import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftReference } from '@lincyaw/kernel'
2
+ import { draftContentHash } from './authoring-draft-fragment.ts'
3
+ import { componentDraftMetadata, componentDraftReference } from './component-draft-inspection.ts'
4
+ import type { ComponentPropertyDraft, ComponentPropertyDrafts } from './component-property-drafts.ts'
5
+
6
+ export const sameComponentDraftReference = (a: ClientComponentDraftReference | null, b: ClientComponentDraftReference | null): boolean => a === null || b === null ? a === b
7
+ : a.id === b.id && a.revision === b.revision && a.token === b.token && a.blockId === b.blockId && a.stepId === b.stepId
8
+
9
+ /** Explicit form actions reuse the human bank; they never apply parameters to lecture.json. */
10
+ export async function mutateComponentDrafts(bank: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.restore' | 'component-drafts.discard' }>, signal: AbortSignal, owned: (draft?: ComponentPropertyDraft) => void, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
11
+ const read = await bank.readForAgent(context, signal, action.draft)
12
+ owned()
13
+ const candidate = read.candidates[0]!, target = candidate.record.target, previous = bank.get(target)
14
+ owned(candidate.record)
15
+ const unchanged = () => { signal.throwIfAborted(); owned(candidate.record); if (bank.get(target) !== previous) throw new Error('client-component-drafts-changed') }
16
+ if (action.type === 'component-drafts.discard') {
17
+ if (candidate.current && (!previous || previous.id !== candidate.record.id || previous.revision !== candidate.record.revision)) throw new Error('client-component-drafts-changed')
18
+ if (candidate.current !== action.current) throw new Error('client-component-drafts-changed')
19
+ if (candidate.current && (candidate.storage !== 'durable' || candidate.status !== 'available' || candidate.record.composing || bank.status(target) !== 'available')) throw new Error('client-component-draft-needs-human')
20
+ const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft, current: action.current, discarded: true }
21
+ if (!fits(result)) throw new Error('client-component-result-too-large')
22
+ unchanged()
23
+ const discarded = candidate.current ? await bank.clear(previous!, { preserveOrigins: true }) : await bank.discard(candidate, signal, unchanged)
24
+ signal.throwIfAborted(); owned()
25
+ if (!discarded) throw new Error('client-component-drafts-changed')
26
+ return result
27
+ }
28
+ if (candidate.current || candidate.record.composing) throw new Error('client-component-draft-needs-human')
29
+ if (previous) {
30
+ if (!action.expectedCurrent || action.expectedCurrent.blockId !== target.blockId || action.expectedCurrent.stepId !== target.stepId) throw new Error('client-component-drafts-changed')
31
+ const current = await bank.readForAgent(context, signal, action.expectedCurrent)
32
+ unchanged()
33
+ const value = current.candidates[0]!
34
+ if (!value.current || !sameComponentDraftReference(componentDraftReference(value), action.expectedCurrent)) throw new Error('client-component-drafts-changed')
35
+ if (value.storage !== 'durable' || value.status !== 'available' || value.record.composing || bank.status(target) !== 'available') throw new Error('client-component-draft-needs-human')
36
+ } else if (action.expectedCurrent !== null) throw new Error('client-component-drafts-changed')
37
+ unchanged()
38
+ if (!bank.canKeep(target)) throw new Error('client-component-drafts-limit')
39
+ const projected = { ...candidate.record, id: '0'.repeat(36), revision: 1, composing: false, updatedAt: new Date().toISOString() }
40
+ if (!fits({ type: action.type, family: 'component', draft: action.draft,
41
+ restored: componentDraftMetadata({ ...candidate, record: projected, token: '0'.repeat(64), current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent })) throw new Error('client-component-result-too-large')
42
+ const restored = await bank.recover(candidate, previous, signal, unchanged)
43
+ signal.throwIfAborted(); owned()
44
+ if (!restored) throw new Error('client-component-drafts-changed')
45
+ const record = bank.get(target)
46
+ if (!record || record.id === candidate.record.id || record.id === previous?.id || record.composing || bank.status(target) !== 'available') throw new Error('client-component-drafts-changed')
47
+ const token = await draftContentHash(JSON.stringify(record))
48
+ signal.throwIfAborted(); owned()
49
+ if (bank.get(target) !== record) throw new Error('client-component-drafts-changed')
50
+ const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft,
51
+ restored: componentDraftMetadata({ record, token, key: candidate.key, current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent }
52
+ if (!fits(result)) throw new Error('client-component-result-too-large')
53
+ return result
54
+ }
@@ -0,0 +1,35 @@
1
+ import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftMetadata } from '@lincyaw/kernel'
2
+ import { authoringDraftFragment, draftContentHash } from './authoring-draft-fragment.ts'
3
+ import type { ComponentPropertyAgentCandidate, ComponentPropertyDrafts } from './component-property-drafts.ts'
4
+
5
+ export const componentDraftReference = ({ record, token }: ComponentPropertyAgentCandidate) => ({ id: record.id, revision: record.revision, token, blockId: record.target.blockId, ...record.target.stepId !== undefined ? { stepId: record.target.stepId } : {} })
6
+ /** Metadata is an explicit whitelist: neither baseline/value nor trusted scope identities are spread. */
7
+ export function componentDraftMetadata(value: ComponentPropertyAgentCandidate, source: CanonicalSource): ClientComponentDraftMetadata {
8
+ const { record } = value
9
+ return { draft: componentDraftReference(value), current: value.current, definitionId: record.definitionId, storage: value.storage, status: value.status, updatedAt: Date.parse(record.updatedAt), baseVersion: record.source.version,
10
+ sourceCurrent: record.source.documentId === source.documentId && record.source.version === source.version && record.source.fingerprint === source.fingerprint, composing: record.composing, reset: record.reset }
11
+ }
12
+
13
+ export async function inspectComponentDrafts(drafts: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.inspect' | 'component-drafts.read' }>, signal: AbortSignal, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
14
+ const read = await drafts.readForAgent(context, signal, action.type === 'component-drafts.read' ? action.draft : undefined)
15
+ const current = () => { signal.throwIfAborted(); if (!drafts.readable(context.identity, context.memberId) || drafts.revision(context.identity, context.memberId) !== read.revision) throw new Error('client-component-drafts-changed') }
16
+ if (action.type === 'component-drafts.inspect') {
17
+ const values = read.candidates.map(value => componentDraftMetadata(value, context.source)), token = await draftContentHash(JSON.stringify({ values, coverage: read.coverage, skippedInvalid: read.skippedInvalid }))
18
+ current()
19
+ if (action.cursor && action.cursor.token !== token) throw new Error('client-component-drafts-changed')
20
+ const start = action.cursor ? values.findIndex(value => value.draft.token === action.cursor!.after) + 1 : 0
21
+ if (action.cursor && start === 0) throw new Error('client-component-drafts-changed')
22
+ const result: Extract<AuthoringClientActionResult, { type: 'component-drafts.inspect' }> = { type: action.type, family: 'component', coverage: read.coverage, skippedInvalid: read.skippedInvalid, token, drafts: [] }
23
+ for (const value of values.slice(start, start + (action.limit ?? 10))) {
24
+ const next = { ...result, drafts: [...result.drafts, value], ...start + result.drafts.length + 1 < values.length ? { next: { after: value.draft.token, token } } : {} }
25
+ if (!fits(next)) break
26
+ result.drafts.push(value)
27
+ }
28
+ if (start < values.length && !result.drafts.length) throw new Error('client-component-metadata-too-large')
29
+ if (start + result.drafts.length < values.length) result.next = { after: result.drafts.at(-1)!.draft.token, token }
30
+ if (!fits(result)) throw new Error('client-component-metadata-too-large')
31
+ return result
32
+ }
33
+ const value = read.candidates[0]!
34
+ return authoringDraftFragment({ text: JSON.stringify(value.record.value), base: { type: action.type, family: 'component', draft: componentDraftReference(value), storage: value.storage, format: 'presmium-component-parameters-json-v1' }, offset: action.offset, maxChars: action.maxChars }, current, fits)
35
+ }
@@ -0,0 +1,24 @@
1
+ .component-properties__content,
2
+ .component-properties__backups,
3
+ .component-properties__references {
4
+ display: grid;
5
+ gap: var(--pm-space-3);
6
+ min-width: 0;
7
+ }
8
+ .component-properties__actions {
9
+ display: flex;
10
+ flex-wrap: wrap;
11
+ align-items: center;
12
+ gap: var(--pm-space-2);
13
+ }
14
+ .component-properties__reference {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ gap: var(--pm-space-2);
19
+ min-width: 0;
20
+ }
21
+ .component-properties__reference > span {
22
+ min-width: 0;
23
+ overflow-wrap: anywhere;
24
+ }
@@ -0,0 +1,45 @@
1
+ import { applyDocumentComponentAction, createDocumentParameterChanges, effectiveComponentParameters } from '@lincyaw/kernel'
2
+ import type { CanonicalSource, DocumentAnswer, DocumentComponentAction, JsonObject, TeachingDocument } from '@lincyaw/kernel'
3
+ import type { ComponentPropertyDraft } from './component-property-drafts.ts'
4
+
5
+ export const sameComponentSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
6
+ export const componentSource = (answer: Pick<DocumentAnswer, 'content' | 'version' | 'fingerprint'>): CanonicalSource => ({ documentId: answer.content.id, version: answer.version, fingerprint: answer.fingerprint })
7
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
8
+
9
+ export function componentPropertyAction(draft: ComponentPropertyDraft): DocumentComponentAction | undefined {
10
+ const { blockId, stepId } = draft.target
11
+ if (stepId && draft.reset) return { type: 'component.step-parameters.reset', blockId, stepId }
12
+ const changes = createDocumentParameterChanges(draft.baseline, draft.value)
13
+ if (!changes.length) return
14
+ return stepId ? { type: 'component.step-parameters.patch', blockId, stepId, changes } : { type: 'component.parameters.patch', blockId, changes }
15
+ }
16
+
17
+ /** A checked rebase borrows the same reducer as the service, preserving unrelated current data. */
18
+ export function rebaseComponentPropertyDraft(document: TeachingDocument, draft: ComponentPropertyDraft): JsonObject {
19
+ const block = document.blocks[draft.target.blockId]
20
+ if (block?.type !== 'component' || block.definitionId !== draft.definitionId) throw new Error('component-definition-changed')
21
+ const action = componentPropertyAction(draft)
22
+ const content = action ? applyDocumentComponentAction(document, action).content : document
23
+ return effectiveComponentParameters(content, block.id, draft.target.stepId).parameters
24
+ }
25
+
26
+ /** An external acknowledgement owns its parameter paths, never a later body tree or sibling form. */
27
+ export function reconcileComponentAction(action: DocumentComponentAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
28
+ try {
29
+ if (answer.content.id !== before.id || current.id !== before.id) return
30
+ const expected = applyDocumentComponentAction(before, action).content
31
+ if (!same(expected, answer.content)) return
32
+ const original = before.blocks[action.blockId], actual = current.blocks[action.blockId]
33
+ if (original?.type !== 'component') return
34
+ // A later native deletion stays deleted. Its history is updated separately from the verified canonical reply.
35
+ if (!actual) return current
36
+ if (actual.type !== 'component' || actual.definitionId !== original.definitionId || !same(actual.parameters, original.parameters)
37
+ || !same(before.definitions[original.definitionId], current.definitions[actual.definitionId])) return
38
+ if ('stepId' in action) {
39
+ const oldStep = before.steps[action.stepId], newStep = current.steps[action.stepId]
40
+ if (!oldStep || !newStep || oldStep.sectionId !== newStep.sectionId || oldStep.inherits !== newStep.inherits
41
+ || !same(effectiveComponentParameters(before, action.blockId, action.stepId), effectiveComponentParameters(current, action.blockId, action.stepId))) return
42
+ }
43
+ return applyDocumentComponentAction(current, action).content
44
+ } catch { return }
45
+ }