@lincyaw/workbench 0.1.0 → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,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
|
+
}
|