@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
package/README.md
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# @lincyaw/workbench
|
|
2
2
|
|
|
3
|
-
Presmium's workbench panels in React
|
|
3
|
+
Presmium's workbench panels in React, for teachers who know no code. The panels are:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- the documents of a workspace, listed by title (made from a template, renamed, copied and deleted by title);
|
|
6
|
+
- a document shown as its readers see it (the web form or the slides, driven over Presmium's preview protocol), edited right on the page;
|
|
7
|
+
- the document's comments and its version history.
|
|
8
|
+
|
|
9
|
+
Under them, bound to no framework, are the client of the workspace service (`presmium serve`), the workbench's state, the mapping of an edit on the page back to the source, the preview protocol's workbench side, and the shared components of Presmium's design (`PmButton`, `PmDialog`, `PmMenu` and the others) in React. Experimental (0.x).
|
|
10
|
+
|
|
11
|
+
Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
|
|
6
12
|
|
|
7
13
|
## Usage
|
|
8
14
|
|
|
9
|
-
`presmium studio` shows these panels in DeepSeek Harness's web client: `@lincyaw/dsh` bundles them into its client part and places them. They know nothing of the host they are drawn in
|
|
15
|
+
`presmium studio` shows these panels in DeepSeek Harness's web client: `@lincyaw/dsh` bundles them into its client part and places them. They know nothing of the host they are drawn in. A layout gives them one `Workbench` and places them:
|
|
10
16
|
|
|
11
17
|
```tsx
|
|
12
|
-
import {
|
|
18
|
+
import { DocumentView, HomePanel, Workbench, WorkbenchProvider, workspaceClient } from '@lincyaw/workbench'
|
|
13
19
|
import '@lincyaw/design/design.css'
|
|
14
20
|
import '@lincyaw/workbench/styles.css'
|
|
15
21
|
|
|
@@ -20,15 +26,41 @@ export function Centre({ open }: { open: boolean }) {
|
|
|
20
26
|
}
|
|
21
27
|
```
|
|
22
28
|
|
|
23
|
-
The workspace service's routes
|
|
29
|
+
The page reaches the workspace service's routes under its own `api/presmium/` path; the host passes them on to the service. React 18 is a peer dependency. Dialogs and menus wrap Radix Primitives. Presmium maintains the visual tokens, the editing, the versions and the preview.
|
|
30
|
+
|
|
31
|
+
A host exposing a subset of the workspace service can set `client.capabilities` before constructing `Workbench`: `{ diagnostics: 'none', production: false, narration: false, export: false, readOnly: true }`. This disables diagnostic subscriptions and reports, hides the corresponding document controls, and makes the source session and canonical write requests read-only. Omitted fields preserve the local workspace's complete behavior. Hosts explain unavailable services in their own interface and enforce authorization on every server request. The optional third `workspaceClient` argument, `{ fetch }`, provides an instance-specific transport for authentication headers.
|
|
32
|
+
|
|
33
|
+
`client.capabilities.readOnly: true` also disables canonical document writes, including source transactions, asset imports, comments, page operations and history changes. The source actor and editor controls share this restriction; the service still checks the authenticated member on every request.
|
|
34
|
+
|
|
35
|
+
`DocumentView` keeps the document title, toolbar, editor and preview in one shared view. Its optional `onBack()` callback replaces the default return to the workspace's document list. `actions` places host controls, such as sharing, beside the document's own commands; hosts provide localized accessible names and use the public workbench controls.
|
|
36
|
+
|
|
37
|
+
`historyRevisionAction` adds an explicit host operation to the existing Version History rows. It uses the exported `HistoryRevisionAction` configuration: `label`, optional `icon`, `onSelect(revision)`, optional `disabled` (a boolean or a predicate of the revision), and optional `pendingRevision` (the opaque revision ID being processed). The standalone `HistoryPanel` accepts the same configuration as `revisionAction`. The host owns confirmation, source flushing, authorization, conflict checks and request recovery; it controls pending state while the shared panel disables its other history operations and pagination. Read-only clients cannot activate the action.
|
|
38
|
+
|
|
39
|
+
An absolute snapshot restore is separate from selective undo. A host that stores full snapshots can return `revertible: false` in its history rows and supply a clearly named restore action. Treat revision IDs as opaque references and resolve them on the server; do not reinterpret them in the browser or fetch another history list. Reuse the ordinary compilation and presentation flow, which checks the exact saved source, fingerprint and revision before showing a presentation.
|
|
40
|
+
|
|
41
|
+
## On the page
|
|
42
|
+
|
|
43
|
+
The document toolbar holds the navigation pane, Undo and Redo, whether the document is saved, Video production, Captions, Comments, Version History, Presentation preview and Export. Below 768 px, Video production, Captions and Version History move into a More menu. When the view owns its title (`framed` is false), Back and the title use their own row; Undo, Redo and Comments join the same More menu so the main commands fit within the available width. A framed view retains its existing single command row.
|
|
44
|
+
|
|
45
|
+
The names follow Microsoft Word's, in Chinese and English.
|
|
46
|
+
|
|
47
|
+
The document is one continuous editor: the author types in place and the change is saved after a short pause. Selecting text opens the formatting toolbar, which includes Add comment. Generated content (components and diagrams) shows its preview, not its source; authors change it through its properties or the AI assistant.
|
|
48
|
+
|
|
49
|
+
Every save is a version. Undo and Redo (Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z) walk the versions, and Version History undoes any one of them.
|
|
50
|
+
|
|
51
|
+
The AI's turns become versions too. When the workspace asks to review them (`review: required`), the author accepts or rejects them first.
|
|
52
|
+
|
|
53
|
+
Comments are saved in the document with the text they point at. The Comments panel lists them; each can be replied to, reacted to, resolved and reopened.
|
|
54
|
+
|
|
55
|
+
Presentation preview shows the slides with the arrow keys and `Esc` working at once; `Esc` first closes an open menu or dialog, then leaves the presentation.
|
|
24
56
|
|
|
25
|
-
|
|
57
|
+
Operations that an outside AI tool asks to perform appear as Approvals in the app bar, where the author allows or denies them.
|
|
26
58
|
|
|
27
|
-
|
|
59
|
+
The workbench hands requests to the conversation through `workbench.onAgentRequest`; the host binds that to its agent session. Hosts route links to a document's source through `workbench.revealSource(path, line)`, which opens that document and outlines the block at the line.
|
|
28
60
|
|
|
29
61
|
## Documentation
|
|
30
62
|
|
|
31
|
-
- [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/
|
|
63
|
+
- [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/studio.md) (section 7)
|
|
32
64
|
|
|
33
65
|
## License
|
|
34
66
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/workbench",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Presmium's workbench panels in React (
|
|
3
|
+
"version": "0.2.0-rc.0",
|
|
4
|
+
"description": "Presmium's workbench panels in React (the documents, a document edited on its rendered page with comments and version history) with the workspace service's client and the preview protocol's workbench side; @lincyaw/dsh places them in presmium studio (experimental)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -24,34 +24,38 @@
|
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
26
|
".": "./src/index.ts",
|
|
27
|
+
"./components": "./src/components.tsx",
|
|
27
28
|
"./styles.css": "./src/styles.css"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"src"
|
|
31
32
|
],
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@
|
|
34
|
-
"@
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
37
|
-
"@
|
|
38
|
-
"@
|
|
39
|
-
"@
|
|
40
|
-
"@
|
|
41
|
-
"@lezer/highlight": "1.2.5",
|
|
34
|
+
"@blocknote/core": "0.56.0",
|
|
35
|
+
"@blocknote/mantine": "0.56.0",
|
|
36
|
+
"@blocknote/react": "0.56.0",
|
|
37
|
+
"@dnd-kit/dom": "0.5.1",
|
|
38
|
+
"@dnd-kit/react": "0.5.1",
|
|
39
|
+
"@floating-ui/react": "0.27.20",
|
|
40
|
+
"@mantine/core": "8.3.11",
|
|
41
|
+
"@mantine/hooks": "8.3.11",
|
|
42
42
|
"@radix-ui/react-dialog": "^1.3.1",
|
|
43
43
|
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
|
44
|
-
"@radix-ui/react-popover": "^1.
|
|
44
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
45
|
+
"@radix-ui/react-radio-group": "^1.5.1",
|
|
45
46
|
"@radix-ui/react-tabs": "^1.2.1",
|
|
46
|
-
"@radix-ui/react-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"@
|
|
54
|
-
"
|
|
47
|
+
"@radix-ui/react-toolbar": "^1.2.1",
|
|
48
|
+
"@radix-ui/react-tooltip": "^1.4.1",
|
|
49
|
+
"@rjsf/core": "6.11.0",
|
|
50
|
+
"@rjsf/utils": "6.11.0",
|
|
51
|
+
"@rjsf/validator-ajv8": "6.11.0",
|
|
52
|
+
"@tiptap/core": "3.32.0",
|
|
53
|
+
"@tiptap/extension-file-handler": "3.32.0",
|
|
54
|
+
"@tiptap/pm": "3.32.0",
|
|
55
|
+
"katex": "0.18.10",
|
|
56
|
+
"react-resizable-panels": "4.14.5",
|
|
57
|
+
"@lincyaw/design": "1.0.0-rc.1",
|
|
58
|
+
"@lincyaw/kernel": "1.0.0-rc.1"
|
|
55
59
|
},
|
|
56
60
|
"peerDependencies": {
|
|
57
61
|
"react": "^18.3.1",
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
"@types/react": "18.3.28",
|
|
62
66
|
"@types/react-dom": "18.3.7",
|
|
63
67
|
"react": "^18.3.1",
|
|
64
|
-
"react-dom": "^18.3.1"
|
|
68
|
+
"react-dom": "^18.3.1",
|
|
69
|
+
"@lincyaw/compiler": "1.0.0-rc.1"
|
|
65
70
|
}
|
|
66
71
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useId, useRef, useState } from 'react'
|
|
2
|
+
import type { MediaClip, ProductionAssetInspection } from '@lincyaw/kernel'
|
|
3
|
+
import { PmButton, PmField, PmInput, PmNotice, PmRange, PmSelect } from './components.tsx'
|
|
4
|
+
import type { ControlDensity } from './components.tsx'
|
|
5
|
+
import type { Locale } from './messages.ts'
|
|
6
|
+
import { MEDIA_MESSAGES } from './media-messages.ts'
|
|
7
|
+
import { mediaTime, mediaTimeValue as timeValue } from './media-time.ts'
|
|
8
|
+
export { mediaTime } from './media-time.ts'
|
|
9
|
+
|
|
10
|
+
/** Unaccepted input. Untouched fields keep their original numeric precision. */
|
|
11
|
+
export interface MediaClipDraft {
|
|
12
|
+
version: string
|
|
13
|
+
clip: MediaClip
|
|
14
|
+
baseClip?: MediaClip
|
|
15
|
+
startText?: string
|
|
16
|
+
endText?: string
|
|
17
|
+
offsetText?: string
|
|
18
|
+
}
|
|
19
|
+
export interface MediaClipAssetChoice { id: string, label: string }
|
|
20
|
+
export interface MediaClipEditorProps {
|
|
21
|
+
locale: Locale
|
|
22
|
+
density?: ControlDensity
|
|
23
|
+
assets: readonly MediaClipAssetChoice[]
|
|
24
|
+
clips: readonly MediaClip[]
|
|
25
|
+
selectedClipId?: string
|
|
26
|
+
draft: MediaClipDraft
|
|
27
|
+
inspection?: ProductionAssetInspection
|
|
28
|
+
stepDurationMs?: number
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
canApply?: boolean
|
|
31
|
+
loading?: boolean
|
|
32
|
+
busy?: 'import' | 'apply' | 'remove'
|
|
33
|
+
pending?: boolean
|
|
34
|
+
review?: boolean
|
|
35
|
+
storageFailed?: boolean
|
|
36
|
+
failure?: string
|
|
37
|
+
status?: string
|
|
38
|
+
retryImport?: boolean
|
|
39
|
+
onSelectClip(id: string | undefined): void
|
|
40
|
+
onSelectAsset(id: string): void
|
|
41
|
+
onChange(draft: MediaClipDraft): void
|
|
42
|
+
onImport(file: File): void
|
|
43
|
+
onRetryImport(): void
|
|
44
|
+
onRead(): void
|
|
45
|
+
onReview(): void
|
|
46
|
+
onApply(clip: MediaClip): void
|
|
47
|
+
onRemove(): void
|
|
48
|
+
onEditStart(): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function offsetValue(raw: string | undefined, original: number): number | undefined {
|
|
52
|
+
if (raw === undefined || raw === (original / 1000).toFixed(3)) return original
|
|
53
|
+
const text = raw.normalize('NFKC').trim(), value = Number(text) * 1000
|
|
54
|
+
return /^\d+(?:\.\d+)?$/.test(text) && Number.isFinite(value) ? value : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Compose native public fields; only the caller can adopt a clip into the source. */
|
|
58
|
+
export function MediaClipEditor(props: MediaClipEditorProps) {
|
|
59
|
+
const { locale, density = 'compact', assets, clips, selectedClipId, draft, inspection, stepDurationMs, disabled, busy, loading, review } = props
|
|
60
|
+
const t = MEDIA_MESSAGES[locale], file = useRef<HTMLInputElement>(null), composing = useRef(new Set<string>()), [volumePreview, setVolumePreview] = useState<number>()
|
|
61
|
+
const formatId = useId(), rangeErrorId = useId(), clip = draft.clip
|
|
62
|
+
const startMs = timeValue(draft.startText, clip.sourceRange.startMs), endMs = timeValue(draft.endText, clip.sourceRange.endMs), offsetMs = offsetValue(draft.offsetText, clip.attachment.offsetMs)
|
|
63
|
+
const inspected = inspection?.assetId === clip.assetId ? inspection : undefined
|
|
64
|
+
const rangeInvalid = startMs !== undefined && endMs !== undefined && (startMs < 0 || endMs <= startMs || inspected !== undefined && endMs > inspected.durationMs)
|
|
65
|
+
const exceedsStep = stepDurationMs !== undefined && startMs !== undefined && endMs !== undefined && offsetMs !== undefined && offsetMs + endMs - startMs > stepDurationMs
|
|
66
|
+
const rangeError = rangeInvalid ? t.invalidRange : exceedsStep ? t.exceedsStep : undefined
|
|
67
|
+
const rangeDescription = `${formatId}${rangeError ? ` ${rangeErrorId}` : ''}`
|
|
68
|
+
const originalRemoved = Boolean(selectedClipId && !clips.some(clip => clip.id === selectedClipId))
|
|
69
|
+
const valid = Boolean(inspected && startMs !== undefined && endMs !== undefined && offsetMs !== undefined && !rangeInvalid && !exceedsStep && !originalRemoved)
|
|
70
|
+
const submit = () => {
|
|
71
|
+
if (disabled || busy || !props.canApply || review || !valid || composing.current.size) return
|
|
72
|
+
props.onApply({ ...clip, sourceRange: { startMs: startMs!, endMs: endMs! }, attachment: { ...clip.attachment, offsetMs: offsetMs! } })
|
|
73
|
+
}
|
|
74
|
+
const change = (patch: Partial<MediaClipDraft>) => props.onChange({ ...draft, ...patch })
|
|
75
|
+
const composition = (field: string) => ({ onCompositionStart: () => composing.current.add(field), onCompositionEnd: () => { composing.current.delete(field) } })
|
|
76
|
+
return <section className="pm-ui-media-clip-editor" aria-label={t.title} data-density={density} onFocusCapture={props.onEditStart} onPointerDownCapture={props.onEditStart}
|
|
77
|
+
onKeyDown={event => {
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
if (event.key === 'Enter' && event.target instanceof HTMLInputElement && event.target.type !== 'range' && !event.nativeEvent.isComposing && !composing.current.size) { event.preventDefault(); submit() }
|
|
80
|
+
}}>
|
|
81
|
+
<div className="pm-ui-media-clip-editor__heading"><h2>{t.title}</h2><PmButton density={density} loading={busy === 'import'} disabled={disabled || Boolean(busy)} onClick={() => file.current?.click()}>{t.import}</PmButton>
|
|
82
|
+
<input ref={file} type="file" hidden accept="audio/*,.wav,.ogg,.mp3,.m4a,.webm" onChange={event => { const selected = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (selected && !disabled && !busy) props.onImport(selected) }} />
|
|
83
|
+
</div>
|
|
84
|
+
<PmSelect label={t.asset} density={density} value={clip.assetId} disabled={disabled || Boolean(busy)} onChange={event => props.onSelectAsset(event.target.value)}>
|
|
85
|
+
<option value="">{t.chooseAsset}</option>{assets.map(asset => <option key={asset.id} value={asset.id}>{asset.label}</option>)}
|
|
86
|
+
{clip.assetId && !assets.some(asset => asset.id === clip.assetId) && <option value={clip.assetId}>{clip.assetId}</option>}
|
|
87
|
+
</PmSelect>
|
|
88
|
+
{clips.length > 0 || selectedClipId ? <PmSelect label={t.clip} density={density} value={selectedClipId ?? ''} disabled={disabled || Boolean(busy)} onChange={event => props.onSelectClip(event.target.value || undefined)}>
|
|
89
|
+
<option value="">{t.newClip}</option>{clips.map((clip, index) => <option key={clip.id} value={clip.id}>{t.clipLabel(index, assets.find(asset => asset.id === clip.assetId)?.label ?? clip.assetId)}</option>)}
|
|
90
|
+
{originalRemoved && <option value={selectedClipId}>{t.removed}</option>}
|
|
91
|
+
</PmSelect> : null}
|
|
92
|
+
{clip.assetId && <>
|
|
93
|
+
{loading ? <span className="pm-ui-field__hint" role="status">{t.loading}</span> : inspected && <output className="pm-ui-field__hint">{t.original(mediaTime(inspected.durationMs), valid ? mediaTime(endMs! - startMs!) : undefined)}</output>}
|
|
94
|
+
<div className="pm-ui-media-clip-editor__times">
|
|
95
|
+
<PmInput label={t.in} density={density} width="time" maxLength={32} value={draft.startText ?? mediaTime(clip.sourceRange.startMs)} aria-describedby={rangeDescription} aria-invalid={rangeInvalid || undefined}
|
|
96
|
+
readOnly={disabled || !inspected} error={startMs === undefined ? t.invalidTime : undefined} onChange={event => change({ startText: event.target.value })} {...composition('in')} />
|
|
97
|
+
<PmInput label={t.out} density={density} width="time" maxLength={32} value={draft.endText ?? mediaTime(clip.sourceRange.endMs)} aria-describedby={rangeDescription} aria-invalid={Boolean(rangeError) || undefined}
|
|
98
|
+
readOnly={disabled || !inspected} error={endMs === undefined ? t.invalidTime : undefined} onChange={event => change({ endText: event.target.value })} {...composition('out')} />
|
|
99
|
+
</div>
|
|
100
|
+
<span className="pm-ui-field__hint" id={formatId}>{t.timeFormat}</span>
|
|
101
|
+
{rangeError && <span id={rangeErrorId} className="pm-ui-field__error" role="alert">{rangeError}</span>}
|
|
102
|
+
<PmInput label={t.offset} density={density} width="number" inputMode="decimal" maxLength={32} value={draft.offsetText ?? (clip.attachment.offsetMs / 1000).toFixed(3)}
|
|
103
|
+
readOnly={disabled || !inspected} error={offsetMs === undefined ? t.invalidOffset : undefined} onChange={event => change({ offsetText: event.target.value })} {...composition('offset')} />
|
|
104
|
+
<PmField label={t.volume} density={density} group><div className="pm-ui-media-clip-editor__volume">
|
|
105
|
+
<PmRange label={t.volume} density={density} value={clip.volume * 100} max={100} disabled={disabled || !inspected} valueText={value => `${Math.round(value)}%`} onPreviewChange={setVolumePreview}
|
|
106
|
+
onValueCommit={value => change({ clip: { ...clip, volume: value / 100 } })} />
|
|
107
|
+
<output>{Math.round(volumePreview ?? clip.volume * 100)}%</output><PmButton density={density} aria-pressed={Boolean(clip.muted)} disabled={disabled || !inspected} onClick={() => change({ clip: { ...clip, muted: !clip.muted } })}>{clip.muted ? t.unmute : t.mute}</PmButton>
|
|
108
|
+
</div></PmField>
|
|
109
|
+
<div className="pm-ui-media-clip-editor__actions">
|
|
110
|
+
<PmButton density={density} variant="primary" loading={busy === 'apply'} disabled={disabled || Boolean(busy) || !props.canApply || review || !valid} onClick={submit}>{selectedClipId ? t.apply : t.add}</PmButton>
|
|
111
|
+
<PmButton density={density} disabled={disabled || !inspected} onClick={() => change({ clip: { ...clip, sourceRange: { startMs: 0, endMs: inspected!.durationMs } }, startText: undefined, endText: undefined })}>{t.restore}</PmButton>
|
|
112
|
+
{selectedClipId && <PmButton density={density} variant="danger" loading={busy === 'remove'} disabled={disabled || Boolean(busy) || originalRemoved} onClick={props.onRemove}>{t.remove}</PmButton>}
|
|
113
|
+
</div>
|
|
114
|
+
</>}
|
|
115
|
+
{originalRemoved ? <PmNotice tone="warn">{t.removed}</PmNotice> : review ? <PmNotice tone="warn"><span>{t.review}</span><PmButton density={density} disabled={disabled || Boolean(busy) || !props.canApply || !inspected} onClick={props.onReview}>{t.check}</PmButton></PmNotice>
|
|
116
|
+
: clip.assetId && !props.canApply && !busy && <span className="pm-ui-field__hint">{t.unavailable}</span>}
|
|
117
|
+
{props.failure && <PmNotice tone="danger"><span>{props.failure}</span><PmButton density={density} disabled={disabled || Boolean(busy)} onClick={props.retryImport ? props.onRetryImport : props.onRead}>{props.retryImport ? t.retryImport : t.retry}</PmButton></PmNotice>}
|
|
118
|
+
{props.storageFailed && <PmNotice tone="warn">{t.storageFailed}</PmNotice>}
|
|
119
|
+
{props.pending && !props.failure && <span className="pm-ui-field__hint" role="status">{t.pending}</span>}
|
|
120
|
+
{props.status && <span className="pm-ui-field__hint" role="status">{props.status}</span>}
|
|
121
|
+
</section>
|
|
122
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { PmButton, PmField, PmInput, PmSegmented } from './components.tsx'
|
|
4
|
+
import type { Locale } from './messages.ts'
|
|
5
|
+
import { stepNarrationStarts, stepTimingValue } from './step-production.ts'
|
|
6
|
+
import type { ProductionSource, StepDurationDraft, StepDurationKey, StepTimingPatch } from './step-production.ts'
|
|
7
|
+
|
|
8
|
+
const MESSAGES = {
|
|
9
|
+
'zh-CN': {
|
|
10
|
+
timing: '播放时机', start: '配音开始', during: '同步动画', after: '动画后', minimum: '最短停留', trailing: '结束停顿', automatic: '自动', unit: '毫秒',
|
|
11
|
+
source: { step: '本步设置', section: '继承本页', document: '继承文稿默认', unset: '未设置,使用讲授默认' },
|
|
12
|
+
restore: '使用继承的时机', restoreHint: '只把本步的开始时机、最短停留与结束停顿改回继承值;为单个讲稿设置的时机保留。',
|
|
13
|
+
legacy: '继承讲稿设置', mixed: '本步讲稿的开始时机不同。',
|
|
14
|
+
unify: '统一本步时机', unifyHint: '只覆盖本步;改回继承后继续使用讲稿原有的时机。',
|
|
15
|
+
invalid: '未采用:请输入不小于 0 的毫秒数。',
|
|
16
|
+
review: '草稿尚未采用。请核对当前来源设置,再修改或使用继承值。', pending: '输入尚未采用。', storageFailure: '无法在本机保留草稿,请保持页面打开。',
|
|
17
|
+
count: (index: number, value: string) => `讲稿 ${index}:${value}`,
|
|
18
|
+
advancedDraft: '有未完成的视频设置,请在视频制作中处理。',
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
timing: 'Playback timing', start: 'Voiceover starts', during: 'With animation', after: 'After animation', minimum: 'Minimum hold', trailing: 'Trailing pause', automatic: 'Auto', unit: 'ms',
|
|
22
|
+
source: { step: 'This step', section: 'Inherited from page', document: 'Inherited from document', unset: 'Not set; the lecture default applies' },
|
|
23
|
+
restore: 'Use Inherited Timing', restoreHint: 'Returns only this step’s start, minimum hold, and trailing pause to the inherited values. Timing set on individual scripts stays.',
|
|
24
|
+
legacy: 'Inherited from the script', mixed: 'Scripts in this step start at different times.',
|
|
25
|
+
unify: 'Unify Step Timing', unifyHint: 'Applies only to this step. Using inherited timing again returns to each script’s original timing.',
|
|
26
|
+
invalid: 'Not applied: enter a duration of 0 ms or more.',
|
|
27
|
+
review: 'This draft has not been applied. Check the current source setting, then edit it or use the inherited value.', pending: 'Input has not been applied.', storageFailure: 'Couldn’t keep the draft on this device. Keep this page open.',
|
|
28
|
+
count: (index: number, value: string) => `Script ${index}: ${value}`,
|
|
29
|
+
advancedDraft: 'Review the pending settings in Video Production.',
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DurationFieldProps {
|
|
34
|
+
label: string
|
|
35
|
+
value?: number
|
|
36
|
+
source: ProductionSource
|
|
37
|
+
locale: Locale
|
|
38
|
+
readOnly?: boolean
|
|
39
|
+
draft?: StepDurationDraft
|
|
40
|
+
onDraft?(draft: StepDurationDraft | undefined): void
|
|
41
|
+
onChange(value: number | null): void
|
|
42
|
+
onComposition?(composing: boolean): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Short author intent, never an estimated timestamp or a second playback clock. */
|
|
46
|
+
function DurationField({ label, value, source, locale, readOnly, draft, onDraft, onChange, onComposition }: DurationFieldProps) {
|
|
47
|
+
const t = MESSAGES[locale], current = useRef({ onComposition, onChange, onDraft, value })
|
|
48
|
+
current.current = { onComposition, onChange, onDraft, value }
|
|
49
|
+
const [text, setText] = useState(draft?.text ?? (value === undefined ? '' : String(value))), [error, setError] = useState(draft?.error ?? false)
|
|
50
|
+
const focused = useRef(false), composing = useRef(false)
|
|
51
|
+
useEffect(() => { if (!focused.current) { setText(draft?.text ?? (value === undefined ? '' : String(value))); setError(draft?.error ?? false) } }, [value, source, draft?.text, draft?.error, draft?.review])
|
|
52
|
+
useEffect(() => () => { if (composing.current) current.current.onComposition?.(false) }, [])
|
|
53
|
+
const commit = (input: string): boolean => {
|
|
54
|
+
const normalized = input.normalize('NFKC').trim()
|
|
55
|
+
if (!normalized) { setError(false); current.current.onDraft?.(undefined); current.current.onChange(null); return true }
|
|
56
|
+
const number = Number(normalized)
|
|
57
|
+
if (!/^\d+(?:\.\d+)?$/.test(normalized) || !Number.isFinite(number)) { setError(true); current.current.onDraft?.({ text: input, error: true }); return false }
|
|
58
|
+
setError(false); current.current.onDraft?.(undefined); current.current.onChange(number); return true
|
|
59
|
+
}
|
|
60
|
+
return <PmInput label={locale === 'zh-CN' ? `${label}(${t.unit})` : `${label} (${t.unit})`} width="number" inputMode="decimal" maxLength={256} value={text} placeholder={t.automatic}
|
|
61
|
+
hint={draft && !error && !draft.review ? `${t.source[source]} · ${t.pending}` : t.source[source]} error={draft?.review ? t.review : error ? t.invalid : undefined} readOnly={readOnly}
|
|
62
|
+
onFocus={() => { focused.current = true }}
|
|
63
|
+
onChange={event => {
|
|
64
|
+
setText(event.target.value)
|
|
65
|
+
if (readOnly) return
|
|
66
|
+
if (composing.current) { setError(false); current.current.onDraft?.({ text: event.target.value, error: false }) }
|
|
67
|
+
else commit(event.target.value)
|
|
68
|
+
}}
|
|
69
|
+
onBlur={() => {
|
|
70
|
+
focused.current = false
|
|
71
|
+
if (readOnly || composing.current || error || draft) return
|
|
72
|
+
setText(current.current.value === undefined ? '' : String(current.current.value))
|
|
73
|
+
}}
|
|
74
|
+
onCompositionStart={() => { composing.current = true; onComposition?.(true) }}
|
|
75
|
+
onCompositionEnd={event => {
|
|
76
|
+
composing.current = false
|
|
77
|
+
if (!readOnly) commit(event.currentTarget.value)
|
|
78
|
+
queueMicrotask(() => current.current.onComposition?.(false))
|
|
79
|
+
}} />
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface StepProductionEditorProps {
|
|
83
|
+
document: TeachingDocument
|
|
84
|
+
stepId: string
|
|
85
|
+
locale: Locale
|
|
86
|
+
readOnly?: boolean
|
|
87
|
+
advanced?: boolean
|
|
88
|
+
onPause(): void
|
|
89
|
+
onInteraction?(): void
|
|
90
|
+
onChange(patch: StepTimingPatch): void
|
|
91
|
+
drafts?: Partial<Record<StepDurationKey, StepDurationDraft>>
|
|
92
|
+
draftStorageFailure?: boolean
|
|
93
|
+
onDraft?(key: StepDurationKey, draft: StepDurationDraft | undefined): void
|
|
94
|
+
onComposition?(key: StepDurationKey, composing: boolean): void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A reusable view of the canonical step production, including inherited and migrated settings. */
|
|
98
|
+
export function StepProductionEditor({ document, stepId, locale, readOnly, advanced = true, onPause, onInteraction, onChange, drafts, onDraft, draftStorageFailure, onComposition }: StepProductionEditorProps) {
|
|
99
|
+
const t = MESSAGES[locale], [unifying, setUnifying] = useState(false)
|
|
100
|
+
const start = stepTimingValue(document, stepId, 'narrationStart'), minimum = stepTimingValue(document, stepId, 'minimumHoldMs'), trailing = stepTimingValue(document, stepId, 'trailingPauseMs')
|
|
101
|
+
const narrations = stepNarrationStarts(document, stepId), hasOverrides = narrations.some(value => value.overridden)
|
|
102
|
+
const mixed = new Set(narrations.map(value => value.value ?? 'with-animation')).size > 1
|
|
103
|
+
const hasStepTiming = [start, minimum, trailing].some(value => value.source === 'step')
|
|
104
|
+
const apply = (patch: StepTimingPatch) => { if (!readOnly) { onPause(); onChange(patch) } }
|
|
105
|
+
const options = [{ value: 'with-animation', label: t.during, icon: 'timing-during' as const }, { value: 'after-animation', label: t.after, icon: 'timing-after' as const }]
|
|
106
|
+
return <section className="pm-ui-step-production" contentEditable={false} data-step-production={stepId} aria-label={t.timing}
|
|
107
|
+
onFocus={() => { onInteraction?.(); onPause() }} onKeyDown={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}>
|
|
108
|
+
{advanced && <div className="pm-ui-step-production__heading"><span className="pm-ui-field__label">{t.timing}</span>
|
|
109
|
+
<PmButton small variant="ghost" disabled={readOnly || !hasStepTiming && !drafts?.minimumHoldMs && !drafts?.trailingPauseMs} title={t.restoreHint} onClick={() => { onDraft?.('minimumHoldMs', undefined); onDraft?.('trailingPauseMs', undefined); apply({ narrationStart: null, minimumHoldMs: null, trailingPauseMs: null }) }}>{t.restore}</PmButton>
|
|
110
|
+
</div>}
|
|
111
|
+
{!advanced ? <PmField group label={t.start} density="compact" hint={mixed ? t.mixed : undefined}>
|
|
112
|
+
<div className="pm-ui-step-production__actions"><PmSegmented label={t.start} value={mixed ? '' : narrations[0]?.value ?? start.value ?? 'with-animation'} options={options} disabled={readOnly} onChange={value => apply({ narrationStart: value as 'with-animation' | 'after-animation' })} /></div>
|
|
113
|
+
</PmField> : <><PmField group label={t.start} density="compact" hint={hasOverrides ? (mixed ? t.mixed : t.legacy) : t.source[start.source]}>
|
|
114
|
+
{!hasOverrides && <div className="pm-ui-step-production__actions"><PmSegmented label={t.start} value={start.value ?? ''} options={options} disabled={readOnly} onChange={value => apply({ narrationStart: value as 'with-animation' | 'after-animation' })} /></div>}
|
|
115
|
+
{hasOverrides && <>
|
|
116
|
+
<div className="pm-ui-step-production__narration-settings">{narrations.map((entry, index) => <span key={entry.id} className="pm-ui-field__hint">{t.count(index + 1, entry.value === 'after-animation' ? t.after : t.during)}{entry.overridden ? ` · ${t.legacy}` : ` · ${t.source[start.source]}`}</span>)}</div>
|
|
117
|
+
<div className="pm-ui-step-production__actions"><PmButton small disabled={readOnly} onClick={() => setUnifying(value => !value)} aria-expanded={unifying}>{t.unify}</PmButton></div>
|
|
118
|
+
{unifying && <><span className="pm-ui-field__hint">{t.unifyHint}</span><div className="pm-ui-step-production__actions"><PmSegmented label={t.unify} value="" options={options} disabled={readOnly} onChange={value => { apply({ narrationStart: value as 'with-animation' | 'after-animation' }); setUnifying(false) }} /></div></>}
|
|
119
|
+
</>}
|
|
120
|
+
</PmField>
|
|
121
|
+
<div className="pm-ui-step-production__durations">
|
|
122
|
+
<DurationField label={t.minimum} value={minimum.value} source={minimum.source} locale={locale} readOnly={readOnly} draft={drafts?.minimumHoldMs} onDraft={draft => onDraft?.('minimumHoldMs', draft)}
|
|
123
|
+
onChange={value => apply({ minimumHoldMs: value })} onComposition={composing => onComposition?.('minimumHoldMs', composing)} />
|
|
124
|
+
<DurationField label={t.trailing} value={trailing.value} source={trailing.source} locale={locale} readOnly={readOnly} draft={drafts?.trailingPauseMs} onDraft={draft => onDraft?.('trailingPauseMs', draft)}
|
|
125
|
+
onChange={value => apply({ trailingPauseMs: value })} onComposition={composing => onComposition?.('trailingPauseMs', composing)} />
|
|
126
|
+
</div></>}
|
|
127
|
+
{!advanced && (drafts?.minimumHoldMs || drafts?.trailingPauseMs || draftStorageFailure) ? <span className="pm-ui-field__error" role="alert">{t.advancedDraft}</span>
|
|
128
|
+
: draftStorageFailure && <span className="pm-ui-field__error" role="alert">{t.storageFailure}</span>}
|
|
129
|
+
</section>
|
|
130
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { ApprovalRequest } from '@lincyaw/kernel'
|
|
3
|
+
import { PmButton, PmNotice, PmPopover } from './components.tsx'
|
|
4
|
+
import { useMessages, useWorkbench } from './context.tsx'
|
|
5
|
+
import { WorkspaceServerError } from './workspace-client.ts'
|
|
6
|
+
|
|
7
|
+
interface Pending { id: string, request: ApprovalRequest }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The host's operations waiting for the author, such as an external MCP client's import: shown only while one is
|
|
11
|
+
* pending, answered here as in the viewer's "Approvals". The host times them out; a late answer is reported, not retried.
|
|
12
|
+
*/
|
|
13
|
+
export function WorkbenchApprovals({ density = 'compact' }: { density?: 'compact' | 'touch' }) {
|
|
14
|
+
const { client } = useWorkbench()
|
|
15
|
+
const t = useMessages()
|
|
16
|
+
const [pending, setPending] = useState<Pending[]>([])
|
|
17
|
+
const [open, setOpen] = useState(false)
|
|
18
|
+
const [answering, setAnswering] = useState<string>()
|
|
19
|
+
const [notice, setNotice] = useState<string>()
|
|
20
|
+
useEffect(() => client.subscribeApprovals((event) => {
|
|
21
|
+
if (event.type === 'reset') setPending([])
|
|
22
|
+
else if (event.type === 'settled') setPending(list => list.filter(item => item.id !== event.id))
|
|
23
|
+
else setPending(list => list.some(item => item.id === event.id) ? list : [...list, { id: event.id, request: event.request }])
|
|
24
|
+
}), [client])
|
|
25
|
+
useEffect(() => { if (!pending.length) setOpen(false) }, [pending.length])
|
|
26
|
+
if (!pending.length) return null
|
|
27
|
+
const answer = (id: string, allow: boolean) => {
|
|
28
|
+
setAnswering(id); setNotice(undefined)
|
|
29
|
+
void client.answerApproval(id, allow).then(
|
|
30
|
+
() => setPending(list => list.filter(item => item.id !== id)),
|
|
31
|
+
(error: unknown) => {
|
|
32
|
+
if (error instanceof WorkspaceServerError && error.status === 404) {
|
|
33
|
+
setPending(list => list.filter(item => item.id !== id))
|
|
34
|
+
setNotice(t.approvalExpired)
|
|
35
|
+
} else setNotice(t.approvalFailed)
|
|
36
|
+
},
|
|
37
|
+
).finally(() => setAnswering(current => current === id ? undefined : current))
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<PmPopover open={open} onOpenChange={setOpen} label={t.approvalsTitle} name="pmw-approvals" trigger={
|
|
41
|
+
<PmButton variant="accent" density={density}>{t.approvals(pending.length)}</PmButton>
|
|
42
|
+
}>
|
|
43
|
+
<h2 className="pmw-approvals__heading">{t.approvalsTitle}</h2>
|
|
44
|
+
<ul className="pmw-approvals__list">
|
|
45
|
+
{pending.map(({ id, request }) => (
|
|
46
|
+
<li key={id} className="pmw-approvals__item" data-approval={id}>
|
|
47
|
+
<p className="pmw-approvals__title">{request.title}</p>
|
|
48
|
+
<p className="pmw-muted">{t.approvalFrom(t.approvalOrigin[request.from] ?? t.approvalOtherOrigin)}</p>
|
|
49
|
+
{request.detail && <p className="pmw-approvals__detail">{request.detail}</p>}
|
|
50
|
+
<div className="pmw-approvals__actions">
|
|
51
|
+
<PmButton density={density} disabled={answering === id} onClick={() => answer(id, false)}>{t.approvalDeny}</PmButton>
|
|
52
|
+
<PmButton variant="primary" density={density} loading={answering === id ? true : undefined} disabled={answering === id} onClick={() => answer(id, true)}>{t.approvalAllow}</PmButton>
|
|
53
|
+
</div>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
{notice && <PmNotice tone="warn">{notice}</PmNotice>}
|
|
58
|
+
</PmPopover>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What the kitten beside the assistant does, from what the assistant is doing: asking the author to approve
|
|
2
|
+
// something, reading the document it was asked about, writing into it, thinking, just finished, or idle (sitting,
|
|
3
|
+
// then lying down, then asleep the longer nothing happens).
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import type { MascotAction } from '@lincyaw/design/mascot'
|
|
6
|
+
import { useWorkbench, useWorkbenchState } from './context.tsx'
|
|
7
|
+
|
|
8
|
+
const DONE_MS = 2400
|
|
9
|
+
const LOAF_MS = 90_000
|
|
10
|
+
const SLEEP_MS = 300_000
|
|
11
|
+
|
|
12
|
+
export interface AssistantActivity {
|
|
13
|
+
/** The assistant's turn is running. */
|
|
14
|
+
running: boolean
|
|
15
|
+
/** It holds a review of the open document: still working in it, and how many changes it has made. */
|
|
16
|
+
review?: { running: boolean, changes: number }
|
|
17
|
+
/** Operations wait for the author's approval. */
|
|
18
|
+
approvals: number
|
|
19
|
+
/** How long ago the assistant last stopped, in milliseconds (Infinity if it has not run). */
|
|
20
|
+
sinceStopped: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assistantMascotAction(a: AssistantActivity): MascotAction {
|
|
24
|
+
if (a.approvals > 0) return 'ask'
|
|
25
|
+
if (a.review?.running) return a.review.changes > 0 ? 'write' : 'read'
|
|
26
|
+
if (a.running) return 'think'
|
|
27
|
+
if (a.review && a.review.changes > 0) return 'ask'
|
|
28
|
+
if (a.sinceStopped < DONE_MS) return 'done'
|
|
29
|
+
if (a.sinceStopped > SLEEP_MS) return 'sleep'
|
|
30
|
+
if (a.sinceStopped > LOAF_MS) return 'loaf'
|
|
31
|
+
return 'sit'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The kitten's action for the assistant, given whether its turn is running; follows the workbench's review and the
|
|
35
|
+
* operations waiting for approval, and moves on by itself as time passes. */
|
|
36
|
+
export function useAssistantMascot(running: boolean): MascotAction {
|
|
37
|
+
const { client } = useWorkbench()
|
|
38
|
+
// two plain values rather than one object, so the selection compares equal while nothing changes
|
|
39
|
+
const reviewRunning = useWorkbenchState(state => state.review?.running)
|
|
40
|
+
const changes = useWorkbenchState(state => state.review?.changes.length)
|
|
41
|
+
const review = reviewRunning === undefined ? undefined : { running: reviewRunning, changes: changes ?? 0 }
|
|
42
|
+
const [approvals, setApprovals] = useState<string[]>([])
|
|
43
|
+
const [stoppedAt, setStoppedAt] = useState<number>(() => performance.now() - LOAF_MS / 2)
|
|
44
|
+
const [, setNow] = useState(0)
|
|
45
|
+
const busy = running || Boolean(review?.running)
|
|
46
|
+
useEffect(() => client.subscribeApprovals(event => {
|
|
47
|
+
if (event.type === 'reset') setApprovals([])
|
|
48
|
+
else if (event.type === 'settled') setApprovals(list => list.filter(id => id !== event.id))
|
|
49
|
+
else setApprovals(list => list.includes(event.id) ? list : [...list, event.id])
|
|
50
|
+
}), [client])
|
|
51
|
+
useEffect(() => { if (!busy) setStoppedAt(performance.now()) }, [busy])
|
|
52
|
+
// Wake up at the next moment the idle kitten changes what it does.
|
|
53
|
+
const since = performance.now() - stoppedAt
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (busy) return
|
|
56
|
+
const next = [DONE_MS, LOAF_MS, SLEEP_MS].find(ms => ms > since)
|
|
57
|
+
if (next === undefined) return
|
|
58
|
+
const timer = setTimeout(() => setNow(performance.now()), next - since + 50)
|
|
59
|
+
return () => clearTimeout(timer)
|
|
60
|
+
}, [busy, since])
|
|
61
|
+
return assistantMascotAction({ running, review, approvals: approvals.length, sinceStopped: busy ? 0 : since })
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Locale } from './messages.ts'
|
|
2
|
+
|
|
3
|
+
const zh = {
|
|
4
|
+
trim: '录音区间', captionReview: '字幕尚未按裁剪后的录音对齐,请核对。',
|
|
5
|
+
title: '原音频', source: '本步原音', choose: '选择已保存音频', removedAudio: '原音频已移除', adopt: '采用为本步配音', adopted: '已采用', retryAdoption: '重试采用', cancelAdoption: '取消采用', adoptionSourceChanged: '文稿已更新,请重试。', adoptionFailed: '无法采用这段音频,请重试。', start: '开始录音', stop: '停止录音', cancel: '取消', cancelTake: '放弃本次录音', rerecord: '重新录音',
|
|
6
|
+
audition: '试听', stopAudition: '停止试听', save: '保存为音频素材', saving: '正在保存原音…', retry: '重试', discard: '放弃候选',
|
|
7
|
+
ready: '原音候选 · 尚未保存', imported: '已保存为音频素材', requesting: '正在等待麦克风许可', recording: '正在录音', stopping: '正在完成录音…',
|
|
8
|
+
cancelSave: '取消保存', currentSource: '文稿尚未保存,请先完成当前输入。', saveFailed: '无法保存原音。录音已保留,请重试。',
|
|
9
|
+
discardTitle: '放弃这份原音?', discardHint: '未保存的录音将被删除,已保存的音频素材不受影响。', keep: '保留原音',
|
|
10
|
+
failures: {
|
|
11
|
+
'recording-context': '当前环境无法访问麦克风。请使用安全连接,或继续导入音频。',
|
|
12
|
+
'recording-unsupported': '此浏览器无法录制受支持的音频格式。仍可导入音频。',
|
|
13
|
+
'recording-permission': '麦克风未获许可。请检查浏览器权限后重试。',
|
|
14
|
+
'recording-device': '没有可用的麦克风。', 'recording-device-busy': '麦克风当前无法使用,请检查设备后重试。',
|
|
15
|
+
'recording-device-unsupported': '当前浏览器或设备不支持此录音请求。仍可导入音频。',
|
|
16
|
+
'recording-device-ended': '麦克风连接已结束,已保留录到的原音。',
|
|
17
|
+
'recording-failed': '无法完成本次录音。已有原音仍保留。', 'recording-empty': '本次没有录到音频,请重试。',
|
|
18
|
+
'recording-size': '录音超过 16 MiB。请缩短录音后重试,已有候选仍保留。',
|
|
19
|
+
'recording-duration': '已达到十分钟录制上限,录音已停止。',
|
|
20
|
+
'audition-audio': '原音无法播放。请检查音频或重新录制。', 'audition-request': '无法开始试听,请重试。',
|
|
21
|
+
} as Record<string, string>,
|
|
22
|
+
}
|
|
23
|
+
type Messages = typeof zh
|
|
24
|
+
const en: Messages = {
|
|
25
|
+
trim: 'Recording range', captionReview: 'Captions are not aligned to the trimmed recording. Review them.',
|
|
26
|
+
title: 'Original audio', source: 'Original audio for this step', choose: 'Choose Saved Audio', removedAudio: 'Original audio was removed', adopt: 'Use as Step Voiceover', adopted: 'Applied', retryAdoption: 'Retry', cancelAdoption: 'Cancel', adoptionSourceChanged: 'The document changed. Retry.', adoptionFailed: 'Couldn’t use this audio. Try again.', start: 'Start Recording', stop: 'Stop Recording', cancel: 'Cancel', cancelTake: 'Discard This Recording', rerecord: 'Record Again',
|
|
27
|
+
audition: 'Play Sample', stopAudition: 'Stop Sample', save: 'Save as Audio Asset', saving: 'Saving original audio…', retry: 'Retry', discard: 'Discard Candidate',
|
|
28
|
+
ready: 'Original audio candidate · Not saved', imported: 'Saved as an audio asset', requesting: 'Waiting for microphone permission', recording: 'Recording', stopping: 'Finishing recording…',
|
|
29
|
+
cancelSave: 'Cancel', currentSource: 'Finish and save the current document input first.', saveFailed: 'Couldn’t save the original audio. The recording is kept; try again.',
|
|
30
|
+
discardTitle: 'Discard this original audio?', discardHint: 'The recording that is not saved will be deleted. Saved audio assets are kept.', keep: 'Keep Original Audio',
|
|
31
|
+
failures: {
|
|
32
|
+
'recording-context': 'Microphone access is unavailable here. Use a secure connection or import an audio file.',
|
|
33
|
+
'recording-unsupported': 'This browser cannot record a supported audio format. Audio file import is still available.',
|
|
34
|
+
'recording-permission': 'Microphone permission was not granted. Check browser permissions and retry.',
|
|
35
|
+
'recording-device': 'No microphone is available.', 'recording-device-busy': 'The microphone is unavailable. Check the device and retry.',
|
|
36
|
+
'recording-device-unsupported': 'This browser or device does not support the recording request. Audio file import is still available.',
|
|
37
|
+
'recording-device-ended': 'The microphone disconnected. Recorded audio is retained.',
|
|
38
|
+
'recording-failed': 'Couldn’t finish this recording. Any previous original audio is kept.', 'recording-empty': 'No audio was recorded. Retry.',
|
|
39
|
+
'recording-size': 'The recording exceeds 16 MiB. Record a shorter take; any previous candidate is retained.',
|
|
40
|
+
'recording-duration': 'The ten-minute recording limit was reached. Recording stopped.',
|
|
41
|
+
'audition-audio': 'This audio could not be played. Check it or record again.', 'audition-request': 'Couldn’t start listening. Try again.',
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
export const AUDIO_RECORDING_MESSAGES: Record<Locale, Messages> = { 'zh-CN': zh, en }
|