@leiboss/dsh-voiceover 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +187 -2
- package/client/client.js +985 -0
- package/cordis.patch.yml +13 -0
- package/dist/api.d.ts +52 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +301 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +40 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +303 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.js +985 -0
- package/dist/http-types.d.ts +26 -0
- package/dist/http-types.d.ts.map +1 -0
- package/dist/http-types.js +2 -0
- package/dist/http-types.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +60 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +113 -0
- package/dist/llm.js.map +1 -0
- package/dist/operations.d.ts +41 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +77 -0
- package/dist/operations.js.map +1 -0
- package/dist/panel.d.ts +42 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +212 -0
- package/dist/panel.js.map +1 -0
- package/dist/pipeline.d.ts +141 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +519 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/prompts.d.ts +58 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +169 -0
- package/dist/prompts.js.map +1 -0
- package/dist/serve-entry.d.ts +3 -0
- package/dist/serve-entry.d.ts.map +1 -0
- package/dist/serve-entry.js +11 -0
- package/dist/serve-entry.js.map +1 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +82 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/subtitle.d.ts +49 -0
- package/dist/subtitle.d.ts.map +1 -0
- package/dist/subtitle.js +163 -0
- package/dist/subtitle.js.map +1 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +16 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +47 -0
- package/dist/util.js.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +186 -0
- package/dist/validate.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +83 -3
- package/schemas/manifest.schema.json +132 -0
- package/schemas/outline.schema.json +40 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the voiceover plugin: a progress panel for the conversation.
|
|
3
|
+
*
|
|
4
|
+
* Why it polls HTTP instead of reading the tool result: a settled
|
|
5
|
+
* `ToolResultNode` exposes only the model-facing text, not the structured
|
|
6
|
+
* return value, and re-invoking the Tool from the UI would fight the
|
|
7
|
+
* conversation. So the panel extracts the project id from the rendered text and
|
|
8
|
+
* polls `GET /voiceover/panel/<projectId>.json`, which the Host half serves
|
|
9
|
+
* from the same `state.json` the pipeline writes.
|
|
10
|
+
*
|
|
11
|
+
* Two rules this file must never break:
|
|
12
|
+
* 1. A registered view REPLACES the generic tool row, so returning null for a
|
|
13
|
+
* settled result leaves a blank gap. Every result renders something:
|
|
14
|
+
* progress, an error row, or the raw text.
|
|
15
|
+
* 2. The panel never invents freshness. "Last update" comes from the host's
|
|
16
|
+
* state-file mtime, so a stalled run looks stalled.
|
|
17
|
+
*
|
|
18
|
+
* Format: the Host serves this file as the `./client` bundle, so it stays in the
|
|
19
|
+
* lazy-CJS module-loader format; executing it only registers the factory.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: '@leiboss/dsh-voiceover',
|
|
24
|
+
factory: (require) => {
|
|
25
|
+
var module = { exports: {} }
|
|
26
|
+
var exports = module.exports
|
|
27
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
28
|
+
|
|
29
|
+
const react = require('react')
|
|
30
|
+
const h = react.createElement
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- locale
|
|
33
|
+
|
|
34
|
+
/** Locale namespace owned by this plugin. */
|
|
35
|
+
const LOCALE_NS = 'voiceover'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Panel copy. Both languages live here so a missing translation cannot
|
|
39
|
+
* happen: the two dictionaries are checked for parity by a unit test, and
|
|
40
|
+
* the translated string is read at call time so switching language
|
|
41
|
+
* re-renders every mounted panel.
|
|
42
|
+
*/
|
|
43
|
+
const MESSAGES = {
|
|
44
|
+
zh: {
|
|
45
|
+
'group.label': '有声项目进度',
|
|
46
|
+
'card.title': '有声项目',
|
|
47
|
+
'card.notStarted': '未开始',
|
|
48
|
+
'card.errorTitleStatus': '有声项目查询失败',
|
|
49
|
+
'card.errorTitleCreate': '有声项目创建失败',
|
|
50
|
+
'card.errorNext': '修正后重试;项目已存在时再次运行 voiceover_create 会跳过已完成章节。',
|
|
51
|
+
'card.planning': '规划中…',
|
|
52
|
+
'card.preparing': '准备中…',
|
|
53
|
+
'card.unavailable': '进度不可用',
|
|
54
|
+
'card.preparingHint': '准备中…(首章完成后开始显示进度)',
|
|
55
|
+
'card.unavailableHint': '无法读取进度(面板仅在 Web 宿主可用)',
|
|
56
|
+
'count.progress': '{done}/{total} 章',
|
|
57
|
+
'count.preparing': '准备中',
|
|
58
|
+
'progress.label': '生成进度',
|
|
59
|
+
'progress.value': '{total} 章中完成 {done} 章',
|
|
60
|
+
'state.runningLeft': '生成中 · 剩 {count} 章',
|
|
61
|
+
'state.runningChapter': '生成中 · {id} {status}',
|
|
62
|
+
'state.endedFailed': '已结束 · {count} 章失败',
|
|
63
|
+
'state.done': '已完成',
|
|
64
|
+
'state.preparing': '准备中',
|
|
65
|
+
'status.queued': '排队',
|
|
66
|
+
'status.adapted': '改写',
|
|
67
|
+
'status.marked': '标注',
|
|
68
|
+
'status.audited': '质检',
|
|
69
|
+
'status.synthesized': '合成',
|
|
70
|
+
'status.timed': '对齐',
|
|
71
|
+
'status.done': '完成',
|
|
72
|
+
'status.failed': '失败',
|
|
73
|
+
'chip.model': '模型 改写',
|
|
74
|
+
'chip.raw': '按原文朗读',
|
|
75
|
+
'chip.voiceEdge': '配音 Edge 在线',
|
|
76
|
+
'chip.voiceLocal': '配音 本地声音',
|
|
77
|
+
'chip.subsPending': '字幕 待生成',
|
|
78
|
+
'chip.subsExact': '字幕 精确时间轴',
|
|
79
|
+
'chip.subsEstimated': '字幕 估算时间轴',
|
|
80
|
+
'label.subtitles': '字幕',
|
|
81
|
+
'queue.waiting': '正在为 {total} 章排队…',
|
|
82
|
+
'queue.planning': '正在规划章节…',
|
|
83
|
+
'audio.label': '{id} 音频',
|
|
84
|
+
'action.play': '播放',
|
|
85
|
+
'action.stop': '停止',
|
|
86
|
+
'action.retry': '重试',
|
|
87
|
+
'action.retryAria': '重跑 {id} {title}',
|
|
88
|
+
'action.rerun': '重跑 {count} 章',
|
|
89
|
+
'action.resuming': '正在续跑…',
|
|
90
|
+
'hint.stalled': '超过 60 秒没有进度写入,宿主可能重启过;让我重新运行 voiceover_status 续跑。',
|
|
91
|
+
'hint.allDone': '全部 {done} 章已生成(音频 + 字幕)· 需要文件路径时让我运行 voiceover_export。',
|
|
92
|
+
'hint.moreWarnings': '…还有 {count} 条提醒',
|
|
93
|
+
'meta.updated': '更新于 {time}',
|
|
94
|
+
'warn.llmOff': '未配置模型:按原文朗读,未做口语化改写',
|
|
95
|
+
'warn.outlineMerge': '章节规划失败,已按长度均分',
|
|
96
|
+
'warn.outlineBlock': '部分正文块规划失败,已跳过',
|
|
97
|
+
'warn.highSeverity': '质检发现高危问题,已回炉一次',
|
|
98
|
+
'warn.reasonMissing': '未提供原因',
|
|
99
|
+
'err.EDGE_TTS_FAILED': '配音服务失败,检查网络或代理',
|
|
100
|
+
'err.SYNTHESIS_TIMEOUT': '合成超时,可重试',
|
|
101
|
+
'err.TTS_BUSY': '配音队列繁忙,稍后自动重试',
|
|
102
|
+
'err.SPAWN_EPERM': '沙箱禁止创建子进程,需调整进程策略',
|
|
103
|
+
'err.OUTPUT_DIR_NOT_WRITABLE': '输出目录不可写',
|
|
104
|
+
'err.EMPTY_AUDIO': '返回空音频,可重试',
|
|
105
|
+
'err.LLM_SCHEMA_INVALID': '改写结果不合格,已降级',
|
|
106
|
+
'err.CHAPTER_FAILED': '本章失败,可重新创建续跑',
|
|
107
|
+
'err.UPSTREAM_THROTTLED': '配音服务限流,稍后重试',
|
|
108
|
+
'err.ALL_FALLBACKS_FAILED': '所有配音引擎都失败,检查网络或系统语音',
|
|
109
|
+
'err.ENGINE_UNAVAILABLE': '没有可用的配音引擎',
|
|
110
|
+
},
|
|
111
|
+
en: {
|
|
112
|
+
'group.label': 'Voiceover progress',
|
|
113
|
+
'card.title': 'Voiceover',
|
|
114
|
+
'card.notStarted': 'Not started',
|
|
115
|
+
'card.errorTitleStatus': 'Voiceover lookup failed',
|
|
116
|
+
'card.errorTitleCreate': 'Voiceover creation failed',
|
|
117
|
+
'card.errorNext': 'Fix the input and retry; re-running voiceover_create on an existing project skips finished chapters.',
|
|
118
|
+
'card.planning': 'Planning…',
|
|
119
|
+
'card.preparing': 'Preparing…',
|
|
120
|
+
'card.unavailable': 'Progress unavailable',
|
|
121
|
+
'card.preparingHint': 'Preparing… (progress appears after the first chapter)',
|
|
122
|
+
'card.unavailableHint': 'Cannot read progress (the panel needs the Web host)',
|
|
123
|
+
'count.progress': '{done}/{total} chapters',
|
|
124
|
+
'count.preparing': 'Preparing',
|
|
125
|
+
'progress.label': 'Generation progress',
|
|
126
|
+
'progress.value': '{done} of {total} chapters done',
|
|
127
|
+
'state.runningLeft': 'Running · {count} left',
|
|
128
|
+
'state.runningChapter': 'Running · {id} {status}',
|
|
129
|
+
'state.endedFailed': 'Ended · {count} failed',
|
|
130
|
+
'state.done': 'Done',
|
|
131
|
+
'state.preparing': 'Preparing',
|
|
132
|
+
'status.queued': 'Queued',
|
|
133
|
+
'status.adapted': 'Adapting',
|
|
134
|
+
'status.marked': 'Prosody',
|
|
135
|
+
'status.audited': 'Auditing',
|
|
136
|
+
'status.synthesized': 'Synthesizing',
|
|
137
|
+
'status.timed': 'Timing',
|
|
138
|
+
'status.done': 'Done',
|
|
139
|
+
'status.failed': 'Failed',
|
|
140
|
+
'chip.model': 'Model rewrite',
|
|
141
|
+
'chip.raw': 'Read as written',
|
|
142
|
+
'chip.voiceEdge': 'Voice Edge online',
|
|
143
|
+
'chip.voiceLocal': 'Voice local',
|
|
144
|
+
'chip.subsPending': 'Subtitles pending',
|
|
145
|
+
'chip.subsExact': 'Subtitles exact timing',
|
|
146
|
+
'chip.subsEstimated': 'Subtitles estimated',
|
|
147
|
+
'label.subtitles': 'subtitles',
|
|
148
|
+
'queue.waiting': 'Queuing {total} chapters…',
|
|
149
|
+
'queue.planning': 'Planning chapters…',
|
|
150
|
+
'audio.label': '{id} audio',
|
|
151
|
+
'action.play': 'Play',
|
|
152
|
+
'action.stop': 'Stop',
|
|
153
|
+
'action.retry': 'Retry',
|
|
154
|
+
'action.retryAria': 'Retry {id} {title}',
|
|
155
|
+
'action.rerun': 'Retry {count} chapters',
|
|
156
|
+
'action.resuming': 'Resuming…',
|
|
157
|
+
'hint.stalled': 'No progress for over 60 seconds; the host may have restarted. Ask me to run voiceover_status to resume.',
|
|
158
|
+
'hint.allDone': 'All {done} chapters are ready (audio + subtitles). Ask me to run voiceover_export for the file paths.',
|
|
159
|
+
'hint.moreWarnings': '…and {count} more',
|
|
160
|
+
'meta.updated': 'Updated {time}',
|
|
161
|
+
'warn.llmOff': 'No model configured: the text was read as written',
|
|
162
|
+
'warn.outlineMerge': 'Chapter planning failed; split by length instead',
|
|
163
|
+
'warn.outlineBlock': 'Some source blocks failed to plan and were skipped',
|
|
164
|
+
'warn.highSeverity': 'The audit found a high-severity issue; one repair round ran',
|
|
165
|
+
'warn.reasonMissing': 'No reason provided',
|
|
166
|
+
'err.EDGE_TTS_FAILED': 'Voice service failed; check the network or proxy',
|
|
167
|
+
'err.SYNTHESIS_TIMEOUT': 'Synthesis timed out; retry',
|
|
168
|
+
'err.TTS_BUSY': 'Voice queue is busy; retrying shortly',
|
|
169
|
+
'err.SPAWN_EPERM': 'The sandbox blocked a subprocess; adjust the process policy',
|
|
170
|
+
'err.OUTPUT_DIR_NOT_WRITABLE': 'The output directory is not writable',
|
|
171
|
+
'err.EMPTY_AUDIO': 'Empty audio returned; retry',
|
|
172
|
+
'err.LLM_SCHEMA_INVALID': 'The rewrite failed validation and was degraded',
|
|
173
|
+
'err.CHAPTER_FAILED': 'This chapter failed; recreate to resume',
|
|
174
|
+
'err.UPSTREAM_THROTTLED': 'The voice service is rate limiting; retry later',
|
|
175
|
+
'err.ALL_FALLBACKS_FAILED': 'Every voice engine failed; check the network or the system voice',
|
|
176
|
+
'err.ENGINE_UNAVAILABLE': 'No voice engine is available',
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Locale service, when the client exposes one. */
|
|
181
|
+
let localeService = null
|
|
182
|
+
/** Translate function bound to this plugin's namespace. */
|
|
183
|
+
let translate = null
|
|
184
|
+
/**
|
|
185
|
+
* Active locale id, used when no dictionary was registered. Defaults to the
|
|
186
|
+
* product's primary language; the host locale service overrides it on mount.
|
|
187
|
+
*/
|
|
188
|
+
let activeLocale = 'zh'
|
|
189
|
+
|
|
190
|
+
const listeners = new Set()
|
|
191
|
+
|
|
192
|
+
/** Notify mounted panels that copy may have changed. */
|
|
193
|
+
function emitLocaleChange() {
|
|
194
|
+
for (const listener of listeners) listener()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Subscribe to locale changes; returns the unsubscriber. */
|
|
198
|
+
function subscribeLocale(listener) {
|
|
199
|
+
listeners.add(listener)
|
|
200
|
+
return () => listeners.delete(listener)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Best local fallback dictionary. */
|
|
204
|
+
function dictionary() {
|
|
205
|
+
return /^zh/i.test(activeLocale) ? MESSAGES.zh : MESSAGES.en
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Translate one key, with `{name}` interpolation.
|
|
210
|
+
*
|
|
211
|
+
* The host's `t` is consulted first so language packs can override these
|
|
212
|
+
* strings; when it returns the key itself the local dictionary answers.
|
|
213
|
+
*/
|
|
214
|
+
function tr(key, params) {
|
|
215
|
+
let text
|
|
216
|
+
if (translate !== null) {
|
|
217
|
+
const value = translate(key)
|
|
218
|
+
if (typeof value === 'string' && value.length > 0 && value !== key) text = value
|
|
219
|
+
}
|
|
220
|
+
if (text === undefined) text = dictionary()[key] ?? MESSAGES.zh[key] ?? key
|
|
221
|
+
if (params !== undefined) {
|
|
222
|
+
for (const name of Object.keys(params)) {
|
|
223
|
+
text = text.split(`{${name}}`).join(String(params[name]))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return text
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Status word from the dictionary, falling back to the raw status. */
|
|
230
|
+
function label(status) {
|
|
231
|
+
const text = tr(`status.${status}`)
|
|
232
|
+
return text === `status.${status}` ? status : text
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One-line cause for a chapter error code. */
|
|
236
|
+
function errorText(code) {
|
|
237
|
+
const text = tr(`err.${code}`)
|
|
238
|
+
return text === `err.${code}` ? tr('err.CHAPTER_FAILED') : text
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ----------------------------------------------------------------- style
|
|
242
|
+
|
|
243
|
+
/** Dot color per status; active states share the brand accent. */
|
|
244
|
+
const TONE = {
|
|
245
|
+
done: 'var(--dsw-alias-state-success-primary)',
|
|
246
|
+
failed: 'var(--dsw-alias-state-error-primary)',
|
|
247
|
+
queued: 'var(--dsw-alias-state-idle-primary)',
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Font sizes follow the chat scale so a user-adjusted size also applies here. */
|
|
251
|
+
const FONT = 'var(--dsh-content-font-size-secondary, 13px)'
|
|
252
|
+
const FONT_META = 'calc(var(--dsh-content-font-size-secondary, 13px) - 2px)'
|
|
253
|
+
const RADIUS = 'var(--dsw-radius-md, 10px)'
|
|
254
|
+
|
|
255
|
+
/** Keyboard focus for the panel's own buttons; rendered as a style node. */
|
|
256
|
+
const FOCUS_CSS =
|
|
257
|
+
'.dsh-vo-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:6px}'
|
|
258
|
+
|
|
259
|
+
const S = {
|
|
260
|
+
card: {
|
|
261
|
+
width: '100%',
|
|
262
|
+
display: 'grid',
|
|
263
|
+
gap: 8,
|
|
264
|
+
padding: '10px 12px',
|
|
265
|
+
borderRadius: RADIUS,
|
|
266
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
267
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
268
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
269
|
+
fontSize: FONT,
|
|
270
|
+
boxSizing: 'border-box',
|
|
271
|
+
overflow: 'hidden',
|
|
272
|
+
},
|
|
273
|
+
header: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 },
|
|
274
|
+
title: { fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
275
|
+
count: { flex: 'none', fontSize: FONT_META, color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' },
|
|
276
|
+
track: { height: 6, borderRadius: 999, background: 'var(--dsw-alias-bg-layer-2)', overflow: 'hidden' },
|
|
277
|
+
fill: {
|
|
278
|
+
height: '100%',
|
|
279
|
+
borderRadius: 999,
|
|
280
|
+
background: 'var(--dsw-alias-brand-primary)',
|
|
281
|
+
transition: 'width 240ms ease',
|
|
282
|
+
},
|
|
283
|
+
state: { fontSize: FONT_META, color: 'var(--dsw-alias-label-secondary)' },
|
|
284
|
+
chips: { display: 'flex', flexWrap: 'wrap', gap: 6 },
|
|
285
|
+
chip: {
|
|
286
|
+
fontSize: FONT_META,
|
|
287
|
+
lineHeight: '18px',
|
|
288
|
+
padding: '0 6px',
|
|
289
|
+
borderRadius: 999,
|
|
290
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
291
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
292
|
+
whiteSpace: 'nowrap',
|
|
293
|
+
},
|
|
294
|
+
list: { display: 'grid', gap: 2, maxHeight: 260, overflowY: 'auto' },
|
|
295
|
+
row: { display: 'grid', gridTemplateColumns: '8px 1fr auto auto', alignItems: 'center', gap: 8, minHeight: 22 },
|
|
296
|
+
dot: { width: 8, height: 8, borderRadius: 999 },
|
|
297
|
+
rowTitle: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
298
|
+
meta: {
|
|
299
|
+
fontSize: FONT_META,
|
|
300
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
301
|
+
fontVariantNumeric: 'tabular-nums',
|
|
302
|
+
whiteSpace: 'nowrap',
|
|
303
|
+
maxWidth: 160,
|
|
304
|
+
overflow: 'hidden',
|
|
305
|
+
textOverflow: 'ellipsis',
|
|
306
|
+
},
|
|
307
|
+
warn: { fontSize: FONT_META, color: 'var(--dsw-alias-state-warn-primary)', overflowWrap: 'anywhere' },
|
|
308
|
+
error: { fontSize: FONT_META, color: 'var(--dsw-alias-state-error-primary)', overflowWrap: 'anywhere' },
|
|
309
|
+
footer: { fontSize: FONT_META, color: 'var(--dsw-alias-label-secondary)', display: 'flex', gap: 8, flexWrap: 'wrap' },
|
|
310
|
+
mono: { fontVariantNumeric: 'tabular-nums', userSelect: 'all' },
|
|
311
|
+
playBtn: {
|
|
312
|
+
width: 18,
|
|
313
|
+
height: 18,
|
|
314
|
+
padding: 0,
|
|
315
|
+
border: 'none',
|
|
316
|
+
background: 'transparent',
|
|
317
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
318
|
+
cursor: 'pointer',
|
|
319
|
+
fontSize: FONT_META,
|
|
320
|
+
lineHeight: '18px',
|
|
321
|
+
},
|
|
322
|
+
playerWrap: { display: 'grid', gap: 4 },
|
|
323
|
+
player: { width: '100%', height: 32 },
|
|
324
|
+
cue: { fontSize: FONT, lineHeight: 1.5, color: 'var(--dsw-alias-label-primary)', overflowWrap: 'anywhere' },
|
|
325
|
+
retryBtn: {
|
|
326
|
+
fontSize: FONT_META,
|
|
327
|
+
padding: '0 6px',
|
|
328
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
329
|
+
borderRadius: 6,
|
|
330
|
+
background: 'transparent',
|
|
331
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
332
|
+
cursor: 'pointer',
|
|
333
|
+
},
|
|
334
|
+
actions: { display: 'flex', gap: 8 },
|
|
335
|
+
action: {
|
|
336
|
+
fontSize: FONT_META,
|
|
337
|
+
padding: '2px 10px',
|
|
338
|
+
borderRadius: 6,
|
|
339
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
340
|
+
background: 'transparent',
|
|
341
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
342
|
+
cursor: 'pointer',
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ------------------------------------------------------------------ data
|
|
347
|
+
|
|
348
|
+
/** Stable token emitted by `voiceover_create` and `voiceover_status`. */
|
|
349
|
+
const PROJECT_ID = /projectId:\s*([0-9a-f]{8,24})/i
|
|
350
|
+
|
|
351
|
+
// Cross-mount cache: scrolling a row out of view and back must not flash a
|
|
352
|
+
// loading card, and several status rows for one project share progress.
|
|
353
|
+
const snapshots = new Map()
|
|
354
|
+
const inFlight = new Map()
|
|
355
|
+
|
|
356
|
+
/** Test-only: seed the shared cache so a render test starts from a settled card. */
|
|
357
|
+
function __seedSnapshot(projectId, snapshot) {
|
|
358
|
+
snapshots.set(projectId, snapshot)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Concatenate the model-facing text of a result.
|
|
363
|
+
* @param {{ text?: string }[] | undefined} blocks
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function blocksText(blocks) {
|
|
367
|
+
if (!Array.isArray(blocks)) return ''
|
|
368
|
+
let text = ''
|
|
369
|
+
for (const block of blocks) {
|
|
370
|
+
if (typeof block === 'object' && block !== null && typeof block.text === 'string') {
|
|
371
|
+
text += `${block.text}\n`
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return text
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Pull the project id out of a rendered tool result.
|
|
379
|
+
* @param {{ text?: string }[] | undefined} blocks
|
|
380
|
+
* @returns {string | undefined}
|
|
381
|
+
*/
|
|
382
|
+
function projectIdFrom(blocks) {
|
|
383
|
+
const match = PROJECT_ID.exec(blocksText(blocks))
|
|
384
|
+
return match === null ? undefined : match[1]
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** `m:ss` for a chapter duration; empty when unknown. */
|
|
388
|
+
function formatDuration(ms) {
|
|
389
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return ''
|
|
390
|
+
const total = Math.round(ms / 1000)
|
|
391
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** `HH:MM:SS` in the viewer's clock, or empty for an unknown timestamp. */
|
|
395
|
+
function formatClock(iso) {
|
|
396
|
+
if (typeof iso !== 'string') return ''
|
|
397
|
+
const time = Date.parse(iso)
|
|
398
|
+
if (!Number.isFinite(time) || time <= 0) return ''
|
|
399
|
+
const date = new Date(time)
|
|
400
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
401
|
+
.map((part) => String(part).padStart(2, '0'))
|
|
402
|
+
.join(':')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Parse one SRT timestamp (`HH:MM:SS,mmm`) into milliseconds. */
|
|
406
|
+
function parseTime(value) {
|
|
407
|
+
const match = /^(\d{2}):(\d{2}):(\d{2})[.,](\d{3})$/.exec(String(value ?? '').trim())
|
|
408
|
+
if (match === null) return NaN
|
|
409
|
+
return Number(match[1]) * 3600000 + Number(match[2]) * 60000 + Number(match[3]) * 1000 + Number(match[4])
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Parse an SRT document into cues.
|
|
414
|
+
* @param {string} text
|
|
415
|
+
* @returns {{ startMs: number, endMs: number, text: string }[]}
|
|
416
|
+
*/
|
|
417
|
+
function parseSrt(text) {
|
|
418
|
+
const normalized = String(text ?? '').replace(/\r\n/g, '\n').trim()
|
|
419
|
+
if (normalized.length === 0) return []
|
|
420
|
+
const cues = []
|
|
421
|
+
for (const block of normalized.split('\n\n')) {
|
|
422
|
+
const lines = block.split('\n')
|
|
423
|
+
const timeIndex = lines.findIndex((line) => line.includes('-->'))
|
|
424
|
+
if (timeIndex < 0) continue
|
|
425
|
+
const [startRaw, endRaw] = (lines[timeIndex] ?? '').split('-->').map((part) => part.trim())
|
|
426
|
+
const startMs = parseTime(startRaw)
|
|
427
|
+
const endMs = parseTime(endRaw)
|
|
428
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue
|
|
429
|
+
const text2 = lines.slice(timeIndex + 1).join('\n').trim()
|
|
430
|
+
if (text2.length > 0) cues.push({ startMs, endMs, text: text2 })
|
|
431
|
+
}
|
|
432
|
+
return cues
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The cue visible at `ms`, or an empty string between cues. */
|
|
436
|
+
function cueAt(cues, ms) {
|
|
437
|
+
if (!Array.isArray(cues)) return ''
|
|
438
|
+
for (const cue of cues) {
|
|
439
|
+
if (ms >= cue.startMs && ms < cue.endMs) return cue.text
|
|
440
|
+
}
|
|
441
|
+
return ''
|
|
442
|
+
}
|
|
443
|
+
/** Translate the pipeline's warning strings into one user sentence. */
|
|
444
|
+
function warningText(warning) {
|
|
445
|
+
const text = typeof warning === 'string' ? warning : String(warning)
|
|
446
|
+
if (text.startsWith('capability.llm=false')) return tr('warn.llmOff')
|
|
447
|
+
if (text.startsWith('outline merge failed')) return tr('warn.outlineMerge')
|
|
448
|
+
if (text.startsWith('outline block')) return tr('warn.outlineBlock')
|
|
449
|
+
if (text.includes('high-severity')) return tr('warn.highSeverity')
|
|
450
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Whether a settled result represents a failed call. */
|
|
454
|
+
function isErrorResult(block) {
|
|
455
|
+
if (typeof block !== 'object' || block === null) return false
|
|
456
|
+
if (block.isError === true) return true
|
|
457
|
+
return typeof block.error === 'object' && block.error !== null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Best-effort reason for a failed call, from `error` or the rendered text. */
|
|
461
|
+
function errorMessage(block) {
|
|
462
|
+
const error = block?.error
|
|
463
|
+
if (typeof error === 'string' && error.length > 0) return error
|
|
464
|
+
if (typeof error === 'object' && error !== null) {
|
|
465
|
+
const reason = error.reason ?? error.message ?? error.code
|
|
466
|
+
if (typeof reason === 'string' && reason.length > 0) return reason
|
|
467
|
+
}
|
|
468
|
+
const text = blocksText(block?.content).trim()
|
|
469
|
+
return text.length > 0 ? text.slice(0, 240) : tr('warn.reasonMissing')
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Derived run state; distinguishes "running" from "ended with failures". */
|
|
473
|
+
function stateWord(snapshot) {
|
|
474
|
+
if (snapshot.pending > 0) {
|
|
475
|
+
const active = snapshot.chapters.find(
|
|
476
|
+
(chapter) => chapter.status !== 'queued' && chapter.status !== 'done' && chapter.status !== 'failed',
|
|
477
|
+
)
|
|
478
|
+
const next = snapshot.chapters.find((chapter) => chapter.status === 'queued')
|
|
479
|
+
const current = active ?? next
|
|
480
|
+
return current === undefined
|
|
481
|
+
? tr('state.runningLeft', { count: snapshot.pending })
|
|
482
|
+
: tr('state.runningChapter', { id: current.id, status: label(current.status) })
|
|
483
|
+
}
|
|
484
|
+
if (snapshot.failed > 0) return tr('state.endedFailed', { count: snapshot.failed })
|
|
485
|
+
if (snapshot.total > 0 && snapshot.done === snapshot.total) return tr('state.done')
|
|
486
|
+
return tr('state.preparing')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** One capability pill. Neutral by default: a fallback is a mode, not a fault. */
|
|
490
|
+
function chip(text) {
|
|
491
|
+
return h('span', { key: text, style: S.chip }, text)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** One chapter row. */
|
|
495
|
+
function ChapterRow(props) {
|
|
496
|
+
const chapter = props.chapter
|
|
497
|
+
const playable = typeof chapter.audioUrl === 'string' && chapter.audioUrl.length > 0
|
|
498
|
+
const tone = TONE[chapter.status] ?? 'var(--dsw-alias-brand-primary)'
|
|
499
|
+
const duration = formatDuration(chapter.durationMs)
|
|
500
|
+
const meta =
|
|
501
|
+
chapter.error === undefined
|
|
502
|
+
? [label(chapter.status), duration, chapter.subtitles === undefined ? '' : tr('label.subtitles')]
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
.join(' · ')
|
|
505
|
+
: errorText(chapter.error)
|
|
506
|
+
return h(
|
|
507
|
+
'div',
|
|
508
|
+
{ style: S.row, role: 'listitem', title: chapter.error ?? undefined },
|
|
509
|
+
h('span', { style: { ...S.dot, background: tone } }),
|
|
510
|
+
h('span', { style: S.rowTitle }, `${chapter.id} ${chapter.title}`),
|
|
511
|
+
h(
|
|
512
|
+
'span',
|
|
513
|
+
{ style: chapter.error === undefined ? S.meta : { ...S.meta, color: 'var(--dsw-alias-state-error-primary)' } },
|
|
514
|
+
meta,
|
|
515
|
+
),
|
|
516
|
+
chapter.status === 'failed'
|
|
517
|
+
? h(
|
|
518
|
+
'button',
|
|
519
|
+
{
|
|
520
|
+
type: 'button',
|
|
521
|
+
className: 'dsh-vo-btn',
|
|
522
|
+
style: S.retryBtn,
|
|
523
|
+
'aria-label': tr('action.retryAria', { id: chapter.id, title: chapter.title }),
|
|
524
|
+
disabled: props.retrying,
|
|
525
|
+
onClick: () => props.onRetry(chapter),
|
|
526
|
+
},
|
|
527
|
+
props.retrying ? '…' : tr('action.retry'),
|
|
528
|
+
)
|
|
529
|
+
: playable
|
|
530
|
+
? h(
|
|
531
|
+
'button',
|
|
532
|
+
{
|
|
533
|
+
type: 'button',
|
|
534
|
+
className: 'dsh-vo-btn',
|
|
535
|
+
'aria-pressed': props.playing,
|
|
536
|
+
style: S.playBtn,
|
|
537
|
+
'aria-label': `${props.playing ? tr('action.stop') : tr('action.play')} ${chapter.id} ${chapter.title}`,
|
|
538
|
+
onClick: () => props.onPlay(chapter),
|
|
539
|
+
},
|
|
540
|
+
props.playing ? '■' : '▶',
|
|
541
|
+
)
|
|
542
|
+
: h('span'),
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** A failed call: never render nothing, the generic row is replaced. */
|
|
547
|
+
function ErrorCard(props) {
|
|
548
|
+
return h(
|
|
549
|
+
'div',
|
|
550
|
+
{ style: S.card, role: 'status' },
|
|
551
|
+
h(
|
|
552
|
+
'div',
|
|
553
|
+
{ style: S.header },
|
|
554
|
+
h('span', { style: S.title }, props.title),
|
|
555
|
+
h('span', { style: S.count }, tr('card.notStarted')),
|
|
556
|
+
),
|
|
557
|
+
h('div', { style: S.error }, props.reason),
|
|
558
|
+
h('div', { style: S.state }, props.next),
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Compact row for the phases before a result exists. */
|
|
563
|
+
function PendingCard(props) {
|
|
564
|
+
return h(
|
|
565
|
+
'div',
|
|
566
|
+
{ style: S.card, role: 'status' },
|
|
567
|
+
h(
|
|
568
|
+
'div',
|
|
569
|
+
{ style: S.header },
|
|
570
|
+
h('span', { style: S.title }, tr('card.title')),
|
|
571
|
+
h('span', { style: S.count }, props.label),
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Progress panel for one voiceover project.
|
|
578
|
+
*
|
|
579
|
+
* Hooks run before any early return so the hook order never changes.
|
|
580
|
+
* @param {{ phase?: string, block?: unknown, toolName?: string }} props
|
|
581
|
+
*/
|
|
582
|
+
function VoiceoverPanel(props) {
|
|
583
|
+
const phase = props?.phase
|
|
584
|
+
const block = props?.block
|
|
585
|
+
const isResult = phase === 'result' || phase === undefined
|
|
586
|
+
const failed = isResult && isErrorResult(block)
|
|
587
|
+
const id = isResult && !failed ? projectIdFrom(/** @type {{ text?: string }[]} */ (block)?.content) : undefined
|
|
588
|
+
|
|
589
|
+
const [snap, setSnap] = react.useState(() => (id === undefined ? null : (snapshots.get(id) ?? null)))
|
|
590
|
+
const [problem, setProblem] = react.useState(null)
|
|
591
|
+
// One player, not one per chapter: a 20-chapter project must not mount 20
|
|
592
|
+
// audio elements, and the URL is stored at click time so a snapshot
|
|
593
|
+
// refresh cannot restart playback.
|
|
594
|
+
const [playing, setPlaying] = react.useState(null)
|
|
595
|
+
// Subtitles for the chapter currently playing, and the line at its clock.
|
|
596
|
+
const [cues, setCues] = react.useState([])
|
|
597
|
+
const [cue, setCue] = react.useState('')
|
|
598
|
+
// Bumping this restarts the polling effect, which is how the rerun button
|
|
599
|
+
// gets fresh progress without a second fetch path.
|
|
600
|
+
const [nonce, setNonce] = react.useState(0)
|
|
601
|
+
const [busy, setBusy] = react.useState(false)
|
|
602
|
+
// Which chapter is being retried; one at a time keeps the UI honest.
|
|
603
|
+
const [retrying, setRetrying] = react.useState(null)
|
|
604
|
+
// Re-render when the active locale changes; the copy is read at call time.
|
|
605
|
+
const [, forceLocale] = react.useState(0)
|
|
606
|
+
|
|
607
|
+
react.useEffect(
|
|
608
|
+
() => subscribeLocale(() => forceLocale((value) => value + 1)),
|
|
609
|
+
[],
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
react.useEffect(() => {
|
|
613
|
+
if (id === undefined) return undefined
|
|
614
|
+
let stopped = false
|
|
615
|
+
let timer
|
|
616
|
+
let attempts = 0
|
|
617
|
+
const schedule = (delay) => {
|
|
618
|
+
if (!stopped) timer = setTimeout(tick, delay)
|
|
619
|
+
}
|
|
620
|
+
const tick = async () => {
|
|
621
|
+
try {
|
|
622
|
+
// Several panels for one project share a single request.
|
|
623
|
+
let request = inFlight.get(id)
|
|
624
|
+
if (request === undefined) {
|
|
625
|
+
request = fetch(`/voiceover/panel/${id}.json`, { credentials: 'same-origin', cache: 'no-store' })
|
|
626
|
+
inFlight.set(id, request)
|
|
627
|
+
request.finally(() => inFlight.delete(id)).catch(() => {})
|
|
628
|
+
}
|
|
629
|
+
const res = await request
|
|
630
|
+
if (res.status === 404) {
|
|
631
|
+
// The project exists but has not written state yet (planning or
|
|
632
|
+
// the first chapter). That is a wait, not a broken endpoint.
|
|
633
|
+
if (!stopped) {
|
|
634
|
+
attempts = 0
|
|
635
|
+
setProblem(null)
|
|
636
|
+
schedule(2000)
|
|
637
|
+
}
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
if (!res.ok) {
|
|
641
|
+
if (!stopped) {
|
|
642
|
+
attempts += 1
|
|
643
|
+
if (attempts >= 5) {
|
|
644
|
+
setProblem('unavailable')
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
setProblem('retrying')
|
|
648
|
+
schedule(Math.min(30000, 2000 * 2 ** (attempts - 1)))
|
|
649
|
+
}
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
const next = await res.json()
|
|
653
|
+
if (stopped) return
|
|
654
|
+
attempts = 0
|
|
655
|
+
setProblem(null)
|
|
656
|
+
setSnap(next)
|
|
657
|
+
snapshots.set(id, next)
|
|
658
|
+
// Running projects poll fast; a project that ended with failures
|
|
659
|
+
// polls slowly so a requested rerun becomes visible.
|
|
660
|
+
if (next.pending > 0) schedule(2000)
|
|
661
|
+
else if (next.failed > 0) schedule(10000)
|
|
662
|
+
} catch {
|
|
663
|
+
if (stopped) return
|
|
664
|
+
attempts += 1
|
|
665
|
+
if (attempts >= 5) {
|
|
666
|
+
setProblem('unavailable')
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
setProblem('retrying')
|
|
670
|
+
schedule(Math.min(30000, 2000 * 2 ** (attempts - 1)))
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
tick()
|
|
674
|
+
return () => {
|
|
675
|
+
stopped = true
|
|
676
|
+
clearTimeout(timer)
|
|
677
|
+
}
|
|
678
|
+
}, [id, nonce])
|
|
679
|
+
|
|
680
|
+
if (failed) {
|
|
681
|
+
return h(ErrorCard, {
|
|
682
|
+
title: props?.toolName === 'voiceover_status' ? tr('card.errorTitleStatus') : tr('card.errorTitleCreate'),
|
|
683
|
+
reason: errorMessage(block),
|
|
684
|
+
next: tr('card.errorNext'),
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
if (!isResult) return h(PendingCard, { label: tr('card.planning') })
|
|
688
|
+
if (id === undefined) {
|
|
689
|
+
// A real but unrecognized result: show its text rather than a blank gap.
|
|
690
|
+
const text = blocksText(/** @type {{ text?: string }[]} */ (block)?.content).trim()
|
|
691
|
+
if (text.length === 0) return null
|
|
692
|
+
return h(
|
|
693
|
+
'div',
|
|
694
|
+
{ style: S.card },
|
|
695
|
+
h('div', { style: S.title }, tr('card.title')),
|
|
696
|
+
h('div', { style: S.state }, text.slice(0, 400)),
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (snap === null) {
|
|
701
|
+
return h(PendingCard, {
|
|
702
|
+
label: problem === 'unavailable' ? tr('card.unavailable') : tr('card.preparing'),
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const total = typeof snap.total === 'number' ? snap.total : 0
|
|
707
|
+
const done = typeof snap.done === 'number' ? snap.done : 0
|
|
708
|
+
const failedCount = typeof snap.failed === 'number' ? snap.failed : 0
|
|
709
|
+
const pending = typeof snap.pending === 'number' ? snap.pending : 0
|
|
710
|
+
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0
|
|
711
|
+
const chapters = Array.isArray(snap.chapters) ? snap.chapters : []
|
|
712
|
+
const warnings = Array.isArray(snap.warnings) ? snap.warnings : []
|
|
713
|
+
const warningTotal = typeof snap.warningCount === 'number' ? snap.warningCount : warnings.length
|
|
714
|
+
const capability = snap.capability ?? {}
|
|
715
|
+
const engine = snap.engine ?? {}
|
|
716
|
+
const clock = formatClock(snap.stateUpdatedAt)
|
|
717
|
+
const stalled =
|
|
718
|
+
pending > 0 && typeof snap.stateUpdatedAt === 'string' && Date.now() - Date.parse(snap.stateUpdatedAt) > 60000
|
|
719
|
+
const finished = total > 0 && pending === 0 && failedCount === 0 && done === total
|
|
720
|
+
const voice = engine.name === 'sapi' ? tr('chip.voiceLocal') : tr('chip.voiceEdge')
|
|
721
|
+
const subtitles =
|
|
722
|
+
capability.timingQuality === 'none'
|
|
723
|
+
? tr('chip.subsPending')
|
|
724
|
+
: capability.timingQuality === 'exact'
|
|
725
|
+
? tr('chip.subsExact')
|
|
726
|
+
: tr('chip.subsEstimated')
|
|
727
|
+
|
|
728
|
+
// Same operation the `voiceover_rerun` Tool calls; this is only the
|
|
729
|
+
// transport. Bumping `nonce` restarts the poller so progress resumes.
|
|
730
|
+
const rerun = async () => {
|
|
731
|
+
if (busy) return
|
|
732
|
+
setBusy(true)
|
|
733
|
+
try {
|
|
734
|
+
const res = await fetch(`/voiceover/panel/${id}/rerun`, {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
credentials: 'same-origin',
|
|
737
|
+
headers: { 'content-type': 'application/json' },
|
|
738
|
+
body: '{}',
|
|
739
|
+
})
|
|
740
|
+
if (res.ok) setNonce((value) => value + 1)
|
|
741
|
+
} catch {
|
|
742
|
+
// A dead host is already reported by the poller; leave the button.
|
|
743
|
+
} finally {
|
|
744
|
+
setBusy(false)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Play or stop one chapter, and load its subtitles.
|
|
750
|
+
*
|
|
751
|
+
* The panel is the only place these subtitles are visible: a sidecar
|
|
752
|
+
* `.srt` is invisible in Windows' built-in player and in an <audio>
|
|
753
|
+
* element, so the current line is rendered here instead.
|
|
754
|
+
*/
|
|
755
|
+
const playChapter = async (chapter) => {
|
|
756
|
+
if (playing !== null && playing.id === chapter.id) {
|
|
757
|
+
setPlaying(null)
|
|
758
|
+
setCue('')
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
setPlaying({ id: chapter.id, url: chapter.audioUrl })
|
|
762
|
+
setCue('')
|
|
763
|
+
setCues([])
|
|
764
|
+
if (chapter.subtitles === undefined) return
|
|
765
|
+
try {
|
|
766
|
+
const res = await fetch(`/voiceover/panel/${id}/subtitles/${chapter.id}`, {
|
|
767
|
+
credentials: 'same-origin',
|
|
768
|
+
cache: 'no-store',
|
|
769
|
+
})
|
|
770
|
+
if (res.ok) setCues(parseSrt(await res.text()))
|
|
771
|
+
} catch {
|
|
772
|
+
// Without subtitles the player still works; the line simply stays empty.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
// Per-chapter retry: the same route, narrowed to one chapter. The Tool
|
|
776
|
+
// reaches the same operation through `voiceover_rerun({ chapter })`.
|
|
777
|
+
const retryChapter = async (chapter) => {
|
|
778
|
+
if (retrying !== null) return
|
|
779
|
+
setRetrying(chapter.id)
|
|
780
|
+
try {
|
|
781
|
+
const res = await fetch(`/voiceover/panel/${id}/rerun`, {
|
|
782
|
+
method: 'POST',
|
|
783
|
+
credentials: 'same-origin',
|
|
784
|
+
headers: { 'content-type': 'application/json' },
|
|
785
|
+
body: JSON.stringify({ chapter: chapter.id }),
|
|
786
|
+
})
|
|
787
|
+
if (res.ok) setNonce((value) => value + 1)
|
|
788
|
+
} catch {
|
|
789
|
+
// A dead host is already reported by the poller.
|
|
790
|
+
} finally {
|
|
791
|
+
setRetrying(null)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return h(
|
|
796
|
+
'div',
|
|
797
|
+
{ style: S.card, role: 'group', 'aria-label': tr('group.label') },
|
|
798
|
+
h('style', { key: 'vo-a11y' }, FOCUS_CSS),
|
|
799
|
+
h(
|
|
800
|
+
'div',
|
|
801
|
+
{ style: S.header },
|
|
802
|
+
h('span', { style: S.title }, snap.title || tr('card.title')),
|
|
803
|
+
// The visual count is decorative; the live region below carries the
|
|
804
|
+
// same fact for a screen reader without double-announcing.
|
|
805
|
+
h(
|
|
806
|
+
'span',
|
|
807
|
+
{ style: S.count, 'aria-hidden': 'true' },
|
|
808
|
+
total > 0 ? tr('count.progress', { done, total }) : tr('count.preparing'),
|
|
809
|
+
),
|
|
810
|
+
),
|
|
811
|
+
h(
|
|
812
|
+
'div',
|
|
813
|
+
{
|
|
814
|
+
role: 'progressbar',
|
|
815
|
+
'aria-label': tr('progress.label'),
|
|
816
|
+
'aria-valuemin': 0,
|
|
817
|
+
'aria-valuemax': Math.max(1, total),
|
|
818
|
+
'aria-valuenow': done,
|
|
819
|
+
'aria-valuetext': total > 0 ? tr('progress.value', { done, total }) : tr('count.preparing'),
|
|
820
|
+
style: S.track,
|
|
821
|
+
},
|
|
822
|
+
h('div', { style: { ...S.fill, width: `${pct}%` } }),
|
|
823
|
+
),
|
|
824
|
+
h(
|
|
825
|
+
'div',
|
|
826
|
+
{ style: S.state, 'aria-live': 'polite' },
|
|
827
|
+
stateWord({ ...snap, chapters, pending, failed: failedCount, done, total }),
|
|
828
|
+
),
|
|
829
|
+
h(
|
|
830
|
+
'div',
|
|
831
|
+
{ style: S.chips },
|
|
832
|
+
chip(capability.llm === true ? tr('chip.model') : tr('chip.raw')),
|
|
833
|
+
chip(voice),
|
|
834
|
+
chip(subtitles),
|
|
835
|
+
),
|
|
836
|
+
chapters.length === 0
|
|
837
|
+
? h(
|
|
838
|
+
'div',
|
|
839
|
+
{ style: S.state },
|
|
840
|
+
total > 0 ? tr('queue.waiting', { total }) : tr('queue.planning'),
|
|
841
|
+
)
|
|
842
|
+
: h(
|
|
843
|
+
'div',
|
|
844
|
+
{ style: S.list, role: 'list' },
|
|
845
|
+
chapters.map((chapter) =>
|
|
846
|
+
h(ChapterRow, {
|
|
847
|
+
key: chapter.id,
|
|
848
|
+
chapter,
|
|
849
|
+
playing: playing !== null && playing.id === chapter.id,
|
|
850
|
+
retrying: retrying === chapter.id,
|
|
851
|
+
onPlay: (target) => void playChapter(target),
|
|
852
|
+
onRetry: (target) => void retryChapter(target),
|
|
853
|
+
}),
|
|
854
|
+
),
|
|
855
|
+
),
|
|
856
|
+
playing === null
|
|
857
|
+
? null
|
|
858
|
+
: h(
|
|
859
|
+
'div',
|
|
860
|
+
{ style: S.playerWrap },
|
|
861
|
+
h('audio', {
|
|
862
|
+
key: playing.url,
|
|
863
|
+
src: playing.url,
|
|
864
|
+
controls: true,
|
|
865
|
+
autoPlay: true,
|
|
866
|
+
preload: 'none',
|
|
867
|
+
style: S.player,
|
|
868
|
+
'aria-label': tr('audio.label', { id: playing.id }),
|
|
869
|
+
onTimeUpdate: (event) => setCue(cueAt(cues, event.currentTarget.currentTime * 1000)),
|
|
870
|
+
onEnded: () => {
|
|
871
|
+
setPlaying(null)
|
|
872
|
+
setCue('')
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
cue.length === 0 ? null : h('div', { style: S.cue }, cue),
|
|
876
|
+
),
|
|
877
|
+
failedCount > 0
|
|
878
|
+
? h(
|
|
879
|
+
'div',
|
|
880
|
+
{ style: S.actions },
|
|
881
|
+
h(
|
|
882
|
+
'button',
|
|
883
|
+
{ type: 'button', className: 'dsh-vo-btn', style: S.action, disabled: busy, onClick: rerun },
|
|
884
|
+
busy ? tr('action.resuming') : tr('action.rerun', { count: failedCount }),
|
|
885
|
+
),
|
|
886
|
+
)
|
|
887
|
+
: null,
|
|
888
|
+
warnings.length === 0
|
|
889
|
+
? null
|
|
890
|
+
: h(
|
|
891
|
+
'div',
|
|
892
|
+
{ style: S.warn },
|
|
893
|
+
warnings.slice(0, 3).map((warning, index) => h('div', { key: index }, warningText(warning))),
|
|
894
|
+
warningTotal > 3
|
|
895
|
+
? h('div', { key: 'more' }, tr('hint.moreWarnings', { count: warningTotal - 3 }))
|
|
896
|
+
: null,
|
|
897
|
+
),
|
|
898
|
+
stalled ? h('div', { style: S.warn }, tr('hint.stalled')) : null,
|
|
899
|
+
finished ? h('div', { style: S.state }, tr('hint.allDone', { done })) : null,
|
|
900
|
+
h(
|
|
901
|
+
'div',
|
|
902
|
+
{ style: S.footer },
|
|
903
|
+
h('span', { style: S.mono }, `projectId ${snap.projectId ?? ''}`),
|
|
904
|
+
clock.length === 0 ? null : h('span', null, tr('meta.updated', { time: clock })),
|
|
905
|
+
),
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ----------------------------------------------------------------- mount
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Register this plugin's dictionaries and keep `activeLocale` in sync.
|
|
913
|
+
*
|
|
914
|
+
* Everything is optional: a client without a locale service still renders,
|
|
915
|
+
* falling back to the local default language.
|
|
916
|
+
*/
|
|
917
|
+
function installLocale(ctx) {
|
|
918
|
+
const service = typeof ctx.get === 'function' ? ctx.get('locale') : undefined
|
|
919
|
+
if (service === undefined || service === null) return
|
|
920
|
+
|
|
921
|
+
localeService = service
|
|
922
|
+
try {
|
|
923
|
+
const snapshot = service.getSnapshot?.() ?? service.getLocale?.()
|
|
924
|
+
if (typeof snapshot?.active === 'string') activeLocale = snapshot.active
|
|
925
|
+
} catch {
|
|
926
|
+
// Keep the fallback language.
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
for (const code of Object.keys(MESSAGES)) {
|
|
930
|
+
try {
|
|
931
|
+
const dispose = service.register(LOCALE_NS, code, MESSAGES[code])
|
|
932
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => dispose)
|
|
933
|
+
} catch {
|
|
934
|
+
// A duplicate namespace owner must not break the panel.
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
translate = service.bind(LOCALE_NS)
|
|
940
|
+
} catch {
|
|
941
|
+
translate = null
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
const unsubscribe = service.subscribe(() => {
|
|
946
|
+
try {
|
|
947
|
+
const snapshot = service.getSnapshot?.() ?? service.getLocale?.()
|
|
948
|
+
if (typeof snapshot?.active === 'string') activeLocale = snapshot.active
|
|
949
|
+
} catch {
|
|
950
|
+
// Keep the previous language.
|
|
951
|
+
}
|
|
952
|
+
emitLocaleChange()
|
|
953
|
+
})
|
|
954
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => unsubscribe)
|
|
955
|
+
} catch {
|
|
956
|
+
// Live switching is a nicety; the mount-time language still works.
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
emitLocaleChange()
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function apply(ctx) {
|
|
963
|
+
installLocale(ctx)
|
|
964
|
+
ctx.slots.inject('tool.call.toolview', function* () {
|
|
965
|
+
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'voiceover_create' }, VoiceoverPanel)
|
|
966
|
+
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'voiceover_status' }, VoiceoverPanel)
|
|
967
|
+
})
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
exports.apply = apply
|
|
971
|
+
exports.inject = ['slots']
|
|
972
|
+
exports.__seedSnapshot = __seedSnapshot
|
|
973
|
+
exports.__messages = MESSAGES
|
|
974
|
+
exports.projectIdFrom = projectIdFrom
|
|
975
|
+
exports.blocksText = blocksText
|
|
976
|
+
exports.formatDuration = formatDuration
|
|
977
|
+
exports.warningText = warningText
|
|
978
|
+
exports.stateWord = stateWord
|
|
979
|
+
exports.formatClock = formatClock
|
|
980
|
+
exports.parseSrt = parseSrt
|
|
981
|
+
exports.cueAt = cueAt
|
|
982
|
+
exports.VoiceoverPanel = VoiceoverPanel
|
|
983
|
+
return module.exports
|
|
984
|
+
},
|
|
985
|
+
})
|