@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +187 -2
  3. package/client/client.js +985 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/api.d.ts +52 -0
  6. package/dist/api.d.ts.map +1 -0
  7. package/dist/api.js +301 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/cli.d.ts +40 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +303 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.js +985 -0
  14. package/dist/http-types.d.ts +26 -0
  15. package/dist/http-types.d.ts.map +1 -0
  16. package/dist/http-types.js +2 -0
  17. package/dist/http-types.js.map +1 -0
  18. package/dist/index.d.ts +60 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +410 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/llm.d.ts +60 -0
  23. package/dist/llm.d.ts.map +1 -0
  24. package/dist/llm.js +113 -0
  25. package/dist/llm.js.map +1 -0
  26. package/dist/operations.d.ts +41 -0
  27. package/dist/operations.d.ts.map +1 -0
  28. package/dist/operations.js +77 -0
  29. package/dist/operations.js.map +1 -0
  30. package/dist/panel.d.ts +42 -0
  31. package/dist/panel.d.ts.map +1 -0
  32. package/dist/panel.js +212 -0
  33. package/dist/panel.js.map +1 -0
  34. package/dist/pipeline.d.ts +141 -0
  35. package/dist/pipeline.d.ts.map +1 -0
  36. package/dist/pipeline.js +519 -0
  37. package/dist/pipeline.js.map +1 -0
  38. package/dist/prompts.d.ts +58 -0
  39. package/dist/prompts.d.ts.map +1 -0
  40. package/dist/prompts.js +169 -0
  41. package/dist/prompts.js.map +1 -0
  42. package/dist/serve-entry.d.ts +3 -0
  43. package/dist/serve-entry.d.ts.map +1 -0
  44. package/dist/serve-entry.js +11 -0
  45. package/dist/serve-entry.js.map +1 -0
  46. package/dist/snapshot.d.ts +48 -0
  47. package/dist/snapshot.d.ts.map +1 -0
  48. package/dist/snapshot.js +82 -0
  49. package/dist/snapshot.js.map +1 -0
  50. package/dist/subtitle.d.ts +49 -0
  51. package/dist/subtitle.d.ts.map +1 -0
  52. package/dist/subtitle.js +163 -0
  53. package/dist/subtitle.js.map +1 -0
  54. package/dist/types.d.ts +148 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +10 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/util.d.ts +16 -0
  59. package/dist/util.d.ts.map +1 -0
  60. package/dist/util.js +47 -0
  61. package/dist/util.js.map +1 -0
  62. package/dist/validate.d.ts +15 -0
  63. package/dist/validate.d.ts.map +1 -0
  64. package/dist/validate.js +186 -0
  65. package/dist/validate.js.map +1 -0
  66. package/dist/version.d.ts +3 -0
  67. package/dist/version.d.ts.map +1 -0
  68. package/dist/version.js +3 -0
  69. package/dist/version.js.map +1 -0
  70. package/package.json +83 -3
  71. package/schemas/manifest.schema.json +132 -0
  72. 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
+ })