@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,33 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { PageDeletePlan } from '@lincyaw/kernel'
3
+ import { PmButton, PmDialog, PmNotice } from '../components.tsx'
4
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
5
+
6
+ /** Confirmation is prepared from the source version after pending document edits have finished. */
7
+ export function PageDeleteDialog({ section, title, trigger, onClose }: { section: string, title: string, trigger?: HTMLButtonElement, onClose(): void }) {
8
+ const t = useMessages(), workbench = useWorkbench()
9
+ const [plan, setPlan] = useState<PageDeletePlan>(), [loading, setLoading] = useState(true), [error, setError] = useState<string>(), [attempt, setAttempt] = useState(0)
10
+ const busy = useWorkbenchState(state => Boolean(state.isEditingPage))
11
+ const failure = useWorkbenchState(state => state.pageEditFailure)
12
+ useEffect(() => {
13
+ let current = true
14
+ setLoading(true); setError(undefined)
15
+ void workbench.preparePageDeletion(section).then(answer => { if (current) setPlan(answer) }, () => { if (current) setError(t.pageEditRequestFailed) }).finally(() => { if (current) setLoading(false) })
16
+ return () => { current = false }
17
+ }, [workbench, section, attempt])
18
+ return <PmDialog open label={t.pageDeleteTitle(title)} description={plan ? t.pageDeleteHint(plan.counts.steps, plan.counts.narration) : t.pageDeleteLoading} busy={busy} closeLabel={t.close} onClose={onClose} onCloseAutoFocus={event => {
19
+ const target = trigger?.isConnected ? trigger : document.querySelector<HTMLElement>('.pmw-navigation__item[aria-current]')
20
+ if (target) { event.preventDefault(); target.focus() }
21
+ }}>
22
+ <div className="pmw-operation">
23
+ {plan?.last && <PmNotice>{t.pageDeleteLast}</PmNotice>}
24
+ {plan && !plan.allowed && <PmNotice tone="warn">{workbench.pageDeleteMessage(plan.reason ?? 'page-boundary')}</PmNotice>}
25
+ {(error || failure) && <PmNotice tone="danger">{error || failure}</PmNotice>}
26
+ <div className="pmw-operation__actions">
27
+ <PmButton disabled={busy} onClick={onClose}>{t.cancel}</PmButton>
28
+ {error || failure ? <PmButton disabled={busy} onClick={() => { workbench.clearPageEditFailure(); setAttempt(value => value + 1) }}>{t.retry}</PmButton>
29
+ : <PmButton variant="danger" loading={loading || busy} disabled={!plan?.allowed || loading} onClick={() => { if (plan) void workbench.deletePage(plan).then(saved => { if (saved) onClose() }) }}>{t.pageDelete}</PmButton>}
30
+ </div>
31
+ </div>
32
+ </PmDialog>
33
+ }
@@ -0,0 +1,27 @@
1
+ import { useState } from 'react'
2
+ import { PmButton, PmDialog, PmInput } from '../components.tsx'
3
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
4
+
5
+ /** The submitted title stays local until the source write and compiled page are acknowledged. */
6
+ export function PageRenameDialog({ section, title: original, trigger, onClose }: { section: string, title: string, trigger?: HTMLButtonElement, onClose(): void }) {
7
+ const t = useMessages()
8
+ const workbench = useWorkbench()
9
+ const busy = useWorkbenchState(state => Boolean(state.isEditingPage))
10
+ const failure = useWorkbenchState(state => state.pageEditFailure)
11
+ const [title, setTitle] = useState(original)
12
+ return <PmDialog open label={t.pageRenameTitle} closeLabel={t.close} busy={busy} onClose={onClose} onCloseAutoFocus={event => { if (trigger?.isConnected) { event.preventDefault(); trigger.focus() } }}>
13
+ <form className="pmw-operation" onKeyDown={event => {
14
+ if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)) event.preventDefault()
15
+ }} onSubmit={event => {
16
+ event.preventDefault()
17
+ if (busy || !title.trim()) return
18
+ void workbench.renamePage(section, title.trim()).then(saved => { if (saved) onClose() })
19
+ }}>
20
+ <PmInput label={t.pageTitle} density="regular" value={title} error={failure} required autoFocus readOnly={busy} onChange={event => setTitle(event.target.value)} />
21
+ <div className="pmw-operation__actions">
22
+ <PmButton disabled={busy} onClick={onClose}>{t.cancel}</PmButton>
23
+ <PmButton type="submit" variant="primary" loading={busy} disabled={!title.trim()}>{t.save}</PmButton>
24
+ </div>
25
+ </form>
26
+ </PmDialog>
27
+ }
@@ -0,0 +1,79 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import { createPreviewPool } from '../preview-pool.ts'
4
+ import type { PresentationAction, PreviewFrameStatus } from '../preview-pool.ts'
5
+ import { PmButton, PmNotice } from '../components.tsx'
6
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
7
+ import { slideAspect } from '../workbench.ts'
8
+ import { failureReason } from '../messages.ts'
9
+ import { PreviewChangeTrail } from './ChangeTrail.tsx'
10
+
11
+ /**
12
+ * The open document as its readers see it (the web form or the slides), with the tools for what the author points at.
13
+ * `playbackKeys`: the workbench plays the page, which hands it Space and the left and right arrows.
14
+ */
15
+ export function PreviewPanel({ active = true, onPresentationAction, presentation = false, playbackKeys = false }: { active?: boolean, presentation?: boolean, playbackKeys?: boolean, onPresentationAction?(action: PresentationAction): void }) {
16
+ const presentationAction = useRef(onPresentationAction)
17
+ presentationAction.current = onPresentationAction
18
+ const t = useMessages()
19
+ const workbench = useWorkbench()
20
+ useEffect(() => active ? workbench.observePreviewRuntime() : undefined, [workbench, active])
21
+ const document = useWorkbenchState(state => state.document)
22
+ const documents = useWorkbenchState(state => state.documents)
23
+ const preview = useWorkbenchState(state => state.preview)
24
+ const failure = useWorkbenchState(state => state.previewFailure)
25
+ const renderer = useWorkbenchState(state => state.previewRenderer)
26
+ const snapshotMatches = useWorkbenchState(state => !state.presentationSnapshot || state.webView !== 'present' || state.presentationSnapshot.revision === state.previewIrRevision)
27
+ const preparing = useWorkbenchState(state => Boolean(state.isPreparingPresentation))
28
+ const confirmedRevision = useWorkbenchState(state => presentation ? state.presentationSnapshot?.revision : undefined)
29
+ const reload = useWorkbenchState(state => state.previewReload)
30
+ const aspect = useWorkbenchState(state => (state.previewRenderer === 'slides' ? slideAspect(state.ir) : undefined))
31
+ const container = useRef<HTMLDivElement>(null)
32
+ const stage = useRef<HTMLDivElement>(null)
33
+ const pool = useRef<ReturnType<typeof createPreviewPool>>()
34
+ const [loads, setLoads] = useState(0)
35
+ const [{ ready, mismatch, identityFailure, handshakeFailure, canvasGate }, setStatus] = useState<PreviewFrameStatus>({ ready: false })
36
+ const address = renderer === 'slides' ? preview?.slides : preview?.web
37
+ const title = documents?.documents.find(doc => doc.dir === document)?.title ?? document ?? ''
38
+
39
+ useLayoutEffect(() => {
40
+ if (!container.current) return
41
+ const current = createPreviewPool(container.current, workbench, setStatus, document => {
42
+ setLoads(n => n + 1)
43
+ void workbench.openDocument(document)
44
+ }, action => presentationAction.current?.(action), { presentation, playbackKeys })
45
+ pool.current = current
46
+ return () => { current.dispose(); pool.current = undefined }
47
+ }, [workbench, presentation, playbackKeys])
48
+ useLayoutEffect(() => { pool.current?.confirmPresentation(confirmedRevision) }, [confirmedRevision])
49
+ // A presentation takes the keyboard (arrows, space, Esc) once its frame can be seen, unless the author already focused something else.
50
+ const keyboardTaken = useRef(false)
51
+ useEffect(() => {
52
+ if (!presentation || !active || !ready || keyboardTaken.current) return
53
+ const frame = container.current?.querySelector<HTMLIFrameElement>('iframe[data-active="true"]:not([hidden])')
54
+ const focused = globalThis.document.activeElement
55
+ if (!frame || focused && focused !== globalThis.document.body) return
56
+ frame.focus()
57
+ keyboardTaken.current = globalThis.document.activeElement === frame
58
+ }, [presentation, active, ready, snapshotMatches, canvasGate])
59
+ useLayoutEffect(() => {
60
+ pool.current?.select(active && document !== undefined && !failure && !(renderer === 'slides' && preview && !preview.slides)
61
+ ? { document, renderer, address, title: t.previewTitle(title), reload: loads + reload }
62
+ : undefined)
63
+ }, [active, document, renderer, address, title, loads, reload, failure, t, workbench])
64
+
65
+ return (
66
+ <section className="pmw-preview" aria-label={t.preview} data-ready={(active && document !== undefined && ready && snapshotMatches) || undefined}>
67
+ {mismatch && <PmNotice tone="warn">{t.versionMismatch}</PmNotice>}
68
+ {identityFailure && <PmNotice tone="danger"><span>{t.previewIdentityMismatch}</span><PmButton small onClick={() => workbench.reloadPreview()}>{t.reloadPreview}</PmButton></PmNotice>}
69
+ {handshakeFailure && <PmNotice tone="danger"><span>{t.previewStalled}</span><PmButton small onClick={() => workbench.reloadPreview()}>{t.reloadPreview}</PmButton></PmNotice>}
70
+ {failure && <PmNotice tone="danger"><span>{t.previewFailed(failureReason(t, failure))}</span><PmButton small onClick={() => workbench.retryPreview()}>{t.retry}</PmButton></PmNotice>}
71
+ {(!ready || !snapshotMatches) && !failure && !handshakeFailure && !(renderer === 'slides' && preview && !preview.slides) && <p className="pmw-muted pmw-preview__loading">{presentation && ready && !snapshotMatches && !preparing ? t.presentationOutdated : address ? t.previewLoading : t.previewPreparing}</p>}
72
+ {renderer === 'slides' && preview && !preview.slides && <p className="pmw-muted pmw-preview__loading">{t.noSlides}</p>}
73
+ <div ref={stage} className="pmw-preview__stage">
74
+ <div ref={container} className="pmw-preview__frames" data-renderer={renderer} data-pending-snapshot={!snapshotMatches || undefined} data-presentation-gated={presentation && canvasGate || undefined} style={aspect === undefined ? undefined : { '--pmw-slide-aspect': aspect } as CSSProperties} />
75
+ {!presentation && <PreviewChangeTrail stage={stage} frames={container} />}
76
+ </div>
77
+ </section>
78
+ )
79
+ }
@@ -0,0 +1,122 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { CAPTION_TIMING_MAX_SEGMENTS, captionSourceFingerprint, captionTimingProblem, stepCaptionText } from '@lincyaw/kernel'
3
+ import type { CaptionTiming, CaptionTimingSource, DocumentCaptionTimingAction, ProductionPlan } from '@lincyaw/kernel'
4
+ import { PmButton, PmCard, PmDialog, PmInput, PmNotice } from '../components.tsx'
5
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { CAPTION_TIMING_MESSAGES } from '../caption-timing-messages.ts'
7
+ import type { CaptionTimingDraft as Draft, CaptionTimingRow as Row } from '../caption-timing-drafts.ts'
8
+
9
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
10
+ const seconds = (raw: string) => {
11
+ if (!/^\d+(?:\.\d{1,3})?$/.test(raw.trim())) return undefined
12
+ const value = Math.round(Number(raw.trim()) * 1000)
13
+ return Number.isSafeInteger(value) ? value : undefined
14
+ }
15
+ /** An explicit line-break partition retains every original character, including blank lines. */
16
+ function lineRows(text: string): Row[] {
17
+ const cuts = [0, ...Array.from(text.matchAll(/\n+(?=[^\n]*\S)/g), match => match.index + match[0].length), text.length]
18
+ const ranges: { from: number, to: number }[] = []
19
+ for (let i = 0; i + 1 < cuts.length; i++) {
20
+ const from = cuts[i]!, to = cuts[i + 1]!
21
+ if (from === to) continue
22
+ if (!text.slice(from, to).trim() && ranges.length) ranges[ranges.length - 1]!.to = to
23
+ else if (ranges.length && !text.slice(ranges.at(-1)!.from, ranges.at(-1)!.to).trim()) ranges[ranges.length - 1]!.to = to
24
+ else ranges.push({ from, to })
25
+ }
26
+ return ranges.map(range => ({ ...range, id: crypto.randomUUID(), start: '', end: '' }))
27
+ }
28
+ function valueOf(draft: Draft): CaptionTiming | undefined {
29
+ if (!draft.basis.available || !draft.basis.playbackFingerprint || draft.basis.durationMs === undefined) return
30
+ const rows = draft.rows.map(row => ({ id: row.id, from: row.from, to: row.to, startMs: seconds(row.start), endMs: seconds(row.end) }))
31
+ if (rows.some(row => row.startMs === undefined || row.endMs === undefined)) return
32
+ const value: CaptionTiming = { narrationIds: draft.basis.narrationIds, sourceFingerprint: draft.basis.sourceFingerprint,
33
+ playbackFingerprint: draft.basis.playbackFingerprint, durationMs: draft.basis.durationMs, segments: rows as CaptionTiming['segments'] }
34
+ return captionTimingProblem(value, draft.text) ? undefined : value
35
+ }
36
+
37
+ /** Video-only explicit timing proposals. The shared semantic action and canonical actor own every save. */
38
+ export function ProductionCaptionTiming({ identity, stepId, plan, current, loading, disabled, onChanged }: {
39
+ identity: string, stepId: string, plan?: ProductionPlan, current: boolean, loading: boolean, disabled: boolean, onChanged(): void,
40
+ }) {
41
+ const workbench = useWorkbench(), source = useWorkbenchState(state => state.authoring), locale = useWorkbenchState(state => state.locale), t = CAPTION_TIMING_MESSAGES[locale]
42
+ const document = source?.identity === identity ? source.content : undefined, basis = current ? plan?.pages.flatMap(page => page.steps).find(step => step.stepId === stepId)?.captionTiming : undefined
43
+ const pendingSteps = useWorkbenchState(state => state.captionTimingDraftSteps), storageFailure = useWorkbenchState(state => state.captionTimingDraftStorageFailure)
44
+ const [open, setOpen] = useState(false), [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>(), [saved, setSaved] = useState(false), [, render] = useState(0)
45
+ const drafts = useRef(new Map<string, Draft>()), alive = useRef(true), request = useRef<AbortController>(), needsRefresh = useRef(false), openRef = useRef(false), composing = useRef(false)
46
+ const latest = useRef({ stepId, onChanged }); latest.current = { stepId, onChanged }
47
+ if (!drafts.current.has(stepId)) { const savedDraft = workbench.captionTimingDraft(stepId, identity); if (savedDraft) drafts.current.set(stepId, savedDraft) }
48
+ const draft = drafts.current.get(stepId), value = draft && valueOf(draft), canonical = document?.production.captionTimings?.[stepId], hasDraft = Boolean(pendingSteps?.includes(stepId))
49
+ const changed = draft && source?.version !== draft.version || draft && basis && (!same(draft.basis, basis) || draft.text !== (document && stepCaptionText(document, stepId)))
50
+ const writable = Boolean(source?.version && source.fingerprint && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.recovering && !source.error && !disabled)
51
+ useEffect(() => { alive.current = true; return () => { alive.current = false; request.current?.abort() } }, [])
52
+ useEffect(() => { request.current?.abort(); openRef.current = false; setOpen(false); setBusy(false); setFailure(undefined); setSaved(false) }, [stepId])
53
+ const pending = (next: Draft, changedByAuthor = true) => { drafts.current.set(stepId, next); if (changedByAuthor) workbench.setCaptionTimingDraft(stepId, next, identity); setSaved(false); setFailure(undefined); render(n => n + 1) }
54
+ const initialize = () => {
55
+ if (!document || !source?.version || !basis) return
56
+ const text = stepCaptionText(document, stepId), existing = document.production.captionTimings?.[stepId]
57
+ const original = existing?.sourceFingerprint === captionSourceFingerprint(document, stepId)
58
+ pending({ version: source.version, text, basis: original ? { ...structuredClone(basis), sourceFingerprint: existing.sourceFingerprint, playbackFingerprint: existing.playbackFingerprint, durationMs: existing.durationMs, narrationIds: [...existing.narrationIds] } : structuredClone(basis), rows: original ? existing.segments.map(row => ({ id: row.id, from: row.from, to: row.to, start: String(row.startMs / 1000), end: String(row.endMs / 1000) })) : lineRows(text) }, false)
59
+ }
60
+ useEffect(() => {
61
+ // A plan may arrive after opening. Step/close effects fence the original opening;
62
+ // an existing target draft always wins over this passive initialization.
63
+ if (open && openRef.current && !saved && current && basis && source?.identity === identity && !drafts.current.has(stepId)) initialize()
64
+ }, [open, saved, current, basis, source?.identity, source?.version, identity, stepId])
65
+ const close = () => {
66
+ request.current?.abort(); setBusy(false); openRef.current = false; setOpen(false)
67
+ if (needsRefresh.current) { needsRefresh.current = false; latest.current.onChanged() }
68
+ }
69
+ const apply = async (automatic = false) => {
70
+ if (!document || !source?.version || !source.fingerprint || !writable || busy || composing.current || !automatic && (!value || changed || !current)) return
71
+ const sent = draft, target = stepId, expected = { identity, version: source.version, fingerprint: source.fingerprint }, controller = new AbortController()
72
+ const action: DocumentCaptionTimingAction = automatic ? { type: 'caption.timing.reset', stepId } : { type: 'caption.timing.set', stepId, value: value! }
73
+ request.current = controller; setBusy(true); setFailure(undefined)
74
+ try {
75
+ const answer = await workbench.applyDocumentCaptionTiming(action, crypto.randomUUID(), expected, controller.signal)
76
+ needsRefresh.current = true
77
+ if (!alive.current || workbench.getState().authoring?.identity !== identity) return
78
+ const ownsInput = drafts.current.get(target) === sent
79
+ if (ownsInput) {
80
+ workbench.setCaptionTimingDraft(target, undefined, identity)
81
+ if (automatic) drafts.current.delete(target)
82
+ else if (sent) drafts.current.set(target, { ...sent, version: answer.version })
83
+ }
84
+ if (latest.current.stepId === target) { setSaved(ownsInput); render(n => n + 1) }
85
+ if (!openRef.current) { needsRefresh.current = false; latest.current.onChanged() }
86
+ } catch (error) {
87
+ if (alive.current && request.current === controller && !controller.signal.aborted) setFailure(error instanceof Error && /source|version|playback|revision/.test(error.message) ? t.changed : t.failed)
88
+ } finally { if (alive.current && request.current === controller) { request.current = undefined; setBusy(false) } }
89
+ }
90
+ if (!document?.steps[stepId] || !stepCaptionText(document, stepId).trim()) return null
91
+ return <PmCard>
92
+ <PmButton small disabled={disabled} onClick={() => { if (!drafts.current.has(stepId)) initialize(); setSaved(false); openRef.current = true; setOpen(true) }}>{t.open}</PmButton>
93
+ <PmDialog open={open} label={t.title} closeLabel={t.cancel} onClose={close} footer={<>
94
+ <PmButton small onClick={close}>{saved ? t.done : t.cancel}</PmButton>
95
+ <PmButton small variant="primary" loading={busy} disabled={!writable || !current || !value || Boolean(changed) || same(value, canonical)} onClick={() => void apply()}>{t.apply}</PmButton>
96
+ </>}>
97
+ <div className="pmw-caption-times" onCompositionStart={() => { composing.current = true }} onCompositionEnd={() => { composing.current = false }}>
98
+ <span>{t.step}</span>
99
+ {!saved && basis?.available === false && <PmNotice tone="warn">{t.unavailable}</PmNotice>}
100
+ {!saved && !basis && <PmNotice>{loading ? t.loading : t.prepare}</PmNotice>}
101
+ {changed && <PmNotice tone="warn">{t.changed}<PmButton small disabled={!basis?.available || busy || !writable || draft?.text !== stepCaptionText(document, stepId)} onClick={() => { if (draft && basis && source?.version) pending({ ...draft, version: source.version, basis: structuredClone(basis) }) }}>{t.recheck}</PmButton></PmNotice>}
102
+ {draft?.rows.map((row, index) => <section key={row.id} className="pmw-caption-times" aria-label={t.segment(index + 1)}>
103
+ <p className="pmw-caption-times__text">{draft.text.slice(row.from, row.to)}</p>
104
+ <div className="pmw-caption-times__range">
105
+ {(['start', 'end'] as const).map(key => <PmInput key={key} label={key === 'start' ? t.start : t.end} inputMode="decimal" maxLength={32} value={row[key]}
106
+ aria-invalid={Boolean(row[key] && seconds(row[key]) === undefined) || undefined} readOnly={disabled}
107
+ onChange={event => pending({ ...draft, rows: draft.rows.map(item => item === row ? { ...row, [key]: event.target.value } : item) })} />)}
108
+ </div>
109
+ </section>)}
110
+ {draft && draft.rows.length > CAPTION_TIMING_MAX_SEGMENTS ? <PmNotice tone="warn">{t.limit}</PmNotice> : draft && draft.rows.every(row => row.start && row.end) && !value ? <PmNotice tone="warn">{t.invalid}</PmNotice> : null}
111
+ <div className="pmw-caption-times__actions">
112
+ <PmButton small disabled={!basis?.available || busy || !writable} onClick={() => { if (document && basis && source?.version) pending({ version: source.version, text: stepCaptionText(document, stepId), basis: structuredClone(basis), rows: lineRows(stepCaptionText(document, stepId)) }) }}>{t.split}</PmButton>
113
+ <PmButton small disabled={!canonical || busy || !writable} onClick={() => void apply(true)}>{t.automatic}</PmButton>
114
+ {hasDraft && <PmButton small disabled={busy} onClick={() => { workbench.setCaptionTimingDraft(stepId, undefined, identity); drafts.current.delete(stepId); initialize() }}>{t.discard}</PmButton>}
115
+ </div>
116
+ {storageFailure && <PmNotice tone="warn">{t.storageFailed}</PmNotice>}
117
+ {saved && <PmNotice tone="ok">{t.saved}</PmNotice>}
118
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
119
+ </div>
120
+ </PmDialog>
121
+ </PmCard>
122
+ }
@@ -0,0 +1,156 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { DocumentChangeAnswer, MediaClip, ProductionAssetInspection, ProductionBinding, TeachingDocument } from '@lincyaw/kernel'
3
+ import { MediaClipEditor } from '../MediaClipEditor.tsx'
4
+ import type { MediaClipDraft } from '../MediaClipEditor.tsx'
5
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { keepMediaDrafts, mediaDraftStorageFailed, readMediaDrafts } from '../media-drafts.ts'
7
+ import type { MediaDrafts } from '../media-drafts.ts'
8
+ import { MEDIA_MESSAGES, mediaFailure } from '../media-messages.ts'
9
+
10
+ interface Props {
11
+ identity: string
12
+ version: string
13
+ document: TeachingDocument
14
+ sectionId: string
15
+ stepId: string
16
+ binding?: ProductionBinding
17
+ stepDurationMs?: number
18
+ disabled: boolean
19
+ onChanged(): void
20
+ }
21
+ const newDraft = (version: string, stepId: string): MediaClipDraft => ({ version, clip: { id: `clip_${crypto.randomUUID().replaceAll('-', '')}`, assetId: '', sourceRange: { startMs: 0, endMs: 0 }, attachment: { stepId, offsetMs: 0 }, volume: 1, muted: false } })
22
+ const fromClip = (version: string, clip: MediaClip): MediaClipDraft => ({ version, clip: structuredClone(clip), baseClip: structuredClone(clip) })
23
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
24
+ const draftKey = (clipId?: string) => clipId === undefined ? 'new' : `clip:${clipId}`
25
+ const ownDraft = (entries: MediaDrafts['entries'], key: string) => Object.hasOwn(entries, key) ? entries[key] : undefined
26
+
27
+ /** The source actor owns writes; this adapter owns only stable-target input and request lifetimes. */
28
+ export function ProductionMediaClips({ identity, version, document, sectionId, stepId, binding, stepDurationMs, disabled, onChanged }: Props) {
29
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = MEDIA_MESSAGES[locale]
30
+ const [session, setSession] = useState(() => readMediaDrafts(workbench.client, identity, stepId)), latest = useRef(session)
31
+ const [storageFailed, setStorageFailed] = useState(() => mediaDraftStorageFailed(workbench.client, identity, stepId))
32
+ const [inspection, setInspection] = useState<{ version: string, value: ProductionAssetInspection }>(), [loading, setLoading] = useState(false), [reload, setReload] = useState(0)
33
+ const [busy, setBusy] = useState<'import' | 'apply' | 'remove'>(), [failure, setFailure] = useState<string>(), [status, setStatus] = useState<string>()
34
+ const [retryImport, setRetryImport] = useState(false), file = useRef<File>(), request = useRef<AbortController>()
35
+ const owner = useRef({ active: true, revision: 0 }), blank = useRef<MediaClipDraft>()
36
+ blank.current ??= newDraft(version, stepId)
37
+ const clips = document.production.mediaClips.filter(clip => clip.attachment.stepId === stepId), selectedClipId = session.selectedClipId
38
+ const key = draftKey(selectedClipId), canonical = clips.find(clip => clip.id === selectedClipId)
39
+ const draft = ownDraft(session.entries, key) ?? (canonical ? fromClip(version, canonical) : { ...blank.current, version })
40
+ const snapshot = useRef(draft), selected = useRef(key)
41
+ snapshot.current = draft; selected.current = key; latest.current = session
42
+ const assets = Object.values(document.assets).filter(asset => asset.kind === 'audio').map(asset => ({ id: asset.id, label: asset.title || asset.location.split('/').at(-1) || asset.id }))
43
+ const active = () => owner.current.active && workbench.getState().authoring?.identity === identity
44
+ const remember = (value: MediaDrafts) => {
45
+ latest.current = value; setSession(value)
46
+ setStorageFailed(!keepMediaDrafts(workbench.client, identity, stepId, value))
47
+ }
48
+ const edit = (value: MediaClipDraft) => {
49
+ owner.current.revision++; setFailure(undefined); setStatus(undefined)
50
+ remember({ ...latest.current, entries: { ...latest.current.entries, [selected.current]: value } })
51
+ }
52
+ const pause = () => { workbench.stopAuditions(); workbench.selectDocumentStep(sectionId, stepId) }
53
+ useEffect(() => () => { owner.current.active = false; request.current?.abort(); file.current = undefined }, [])
54
+ useEffect(() => {
55
+ const assetId = draft.clip.assetId, controller = new AbortController(), requestKey = key
56
+ if (!assetId || disabled || !version || inspection?.version === version && inspection.value.assetId === assetId) { setLoading(false); return }
57
+ setLoading(true)
58
+ void workbench.inspectDocumentAudio(assetId, { identity, version }, controller.signal).then(answer => {
59
+ if (controller.signal.aborted || !active()) return
60
+ setInspection({ version: answer.version, value: answer.inspection })
61
+ const current = ownDraft(latest.current.entries, requestKey)
62
+ if (current?.clip.assetId === assetId && current.clip.sourceRange.endMs === 0 && current.startText === undefined && current.endText === undefined) {
63
+ remember({ ...latest.current, entries: { ...latest.current.entries, [requestKey]: { ...current, clip: { ...current.clip, sourceRange: { startMs: 0, endMs: answer.inspection.durationMs } } } } })
64
+ }
65
+ }, error => { if (!controller.signal.aborted && active()) setFailure(mediaFailure(error, locale)) }).finally(() => { if (!controller.signal.aborted && active()) setLoading(false) })
66
+ return () => controller.abort()
67
+ }, [workbench, identity, version, draft.clip.assetId, key, disabled, reload])
68
+ const start = (kind: 'import' | 'apply' | 'remove') => {
69
+ const source = workbench.getState().authoring
70
+ if (!active() || disabled || busy || request.current || source?.version !== version || source.dirty || source.saving || source.loading || source.composing || source.restored || source.error) return undefined
71
+ const controller = new AbortController(); request.current = controller
72
+ setBusy(kind); setFailure(undefined); setStatus(undefined)
73
+ pause()
74
+ return { controller, expected: { identity, version }, revision: owner.current.revision, key: selected.current, draft: structuredClone(snapshot.current) }
75
+ }
76
+ const finish = (controller: AbortController) => { if (request.current === controller) { request.current = undefined; if (active()) setBusy(undefined) } }
77
+ const acknowledge = (answer: Pick<DocumentChangeAnswer, 'version'>, expectedVersion: string) => {
78
+ const entries = Object.fromEntries(Object.entries(latest.current.entries).map(([id, value]) => [id, value.version === expectedVersion ? { ...value, version: answer.version } : value]))
79
+ return { ...latest.current, entries }
80
+ }
81
+ const importAudio = async (value: File) => {
82
+ if (!value.size || value.size > 16 * 1024 * 1024) { setFailure(t.size); setRetryImport(false); return }
83
+ const sent = start('import')
84
+ if (!sent) return
85
+ file.current = value; setRetryImport(false)
86
+ try {
87
+ const answer = await workbench.ensureDocumentAudio(value, sent.expected, sent.controller.signal)
88
+ if (!active() || sent.controller.signal.aborted) return
89
+ const next = acknowledge(answer, sent.expected.version)
90
+ if (owner.current.revision === sent.revision && selected.current === sent.key) {
91
+ const full = newDraft(answer.version, stepId)
92
+ full.clip.assetId = answer.inspection.assetId; full.clip.sourceRange.endMs = answer.inspection.durationMs
93
+ next.selectedClipId = undefined; next.entries.new = full
94
+ setInspection({ version: answer.version, value: answer.inspection })
95
+ }
96
+ remember(next); file.current = undefined; setStatus(t.imported); onChanged()
97
+ } catch (error) { if (active() && !sent.controller.signal.aborted) { setFailure(mediaFailure(error, locale)); setRetryImport(true) } }
98
+ finally { finish(sent.controller) }
99
+ }
100
+ const apply = async (clip: MediaClip) => {
101
+ if (!binding || stepDurationMs === undefined || draft.version !== version || clip.attachment.stepId !== stepId) return
102
+ const sent = start('apply')
103
+ if (!sent) return
104
+ try {
105
+ const answer = await workbench.applyDocumentMediaClip(clip, { ...sent.expected, binding }, sent.controller.signal)
106
+ if (!active() || sent.controller.signal.aborted) return
107
+ const next = acknowledge(answer, sent.expected.version), current = ownDraft(latest.current.entries, sent.key)
108
+ if (sent.key === 'new') blank.current = newDraft(answer.version, stepId)
109
+ if (current && same(current, sent.draft)) delete next.entries[sent.key]
110
+ else if (current) next.entries[sent.key] = { ...ownDraft(next.entries, sent.key)!, baseClip: structuredClone(clip) }
111
+ if (selected.current === sent.key) {
112
+ next.selectedClipId = clip.id
113
+ const pending = ownDraft(next.entries, 'new')
114
+ if (sent.key === 'new' && pending) { next.entries[draftKey(clip.id)] = pending; delete next.entries.new }
115
+ }
116
+ remember(next); setStatus(t.saved); onChanged()
117
+ } catch (error) { if (active() && !sent.controller.signal.aborted) setFailure(mediaFailure(error, locale)) }
118
+ finally { finish(sent.controller) }
119
+ }
120
+ const remove = async () => {
121
+ if (!canonical) return
122
+ const sent = start('remove')
123
+ if (!sent) return
124
+ try {
125
+ const answer = await workbench.removeDocumentMediaClip(canonical.id, sent.expected, sent.controller.signal)
126
+ if (!active() || sent.controller.signal.aborted) return
127
+ const next = acknowledge(answer, sent.expected.version), current = ownDraft(latest.current.entries, sent.key)
128
+ if (!current || same(current, sent.draft)) {
129
+ delete next.entries[sent.key]
130
+ if (selected.current === sent.key) { next.selectedClipId = undefined; blank.current = newDraft(answer.version, stepId) }
131
+ }
132
+ remember(next); setStatus(t.saved); onChanged()
133
+ } catch (error) { if (active() && !sent.controller.signal.aborted) setFailure(mediaFailure(error, locale)) }
134
+ finally { finish(sent.controller) }
135
+ }
136
+ const inspectCurrent = inspection?.version === version && inspection.value.assetId === draft.clip.assetId ? inspection.value : undefined
137
+ return <MediaClipEditor locale={locale} assets={assets} clips={clips} selectedClipId={selectedClipId} draft={draft} inspection={inspectCurrent} stepDurationMs={stepDurationMs}
138
+ disabled={disabled} canApply={Boolean(binding && stepDurationMs !== undefined)} loading={loading} busy={busy} pending={Boolean(ownDraft(session.entries, key))} review={Boolean(ownDraft(session.entries, key) && draft.version !== version)}
139
+ failure={failure} storageFailed={storageFailed} status={status} retryImport={retryImport}
140
+ onEditStart={pause} onChange={edit} onImport={value => void importAudio(value)} onRetryImport={() => { if (file.current) void importAudio(file.current) }}
141
+ onRead={() => { setFailure(undefined); setInspection(undefined); setReload(value => value + 1) }}
142
+ onReview={() => { if (inspectCurrent && (!draft.baseClip || canonical)) edit({ ...draft, version, ...canonical ? { baseClip: structuredClone(canonical) } : {} }) }}
143
+ onSelectClip={id => {
144
+ owner.current.revision++; setFailure(undefined); setStatus(undefined)
145
+ const next = { ...latest.current, selectedClipId: id }
146
+ if (!id && selectedClipId && !canonical) {
147
+ const retained = ownDraft(latest.current.entries, draftKey(selectedClipId))
148
+ if (retained) next.entries = { ...next.entries, new: { ...retained, clip: { ...retained.clip, id: newDraft(version, stepId).clip.id }, baseClip: undefined } }
149
+ }
150
+ remember(next)
151
+ }}
152
+ onSelectAsset={id => {
153
+ const value = snapshot.current
154
+ edit({ ...value, clip: { ...value.clip, assetId: id, sourceRange: { startMs: 0, endMs: 0 } }, startText: undefined, endText: undefined })
155
+ }} onApply={clip => void apply(clip)} onRemove={() => void remove()} />
156
+ }