mulmoclaude 1.2.0 → 1.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 (75) hide show
  1. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
  2. package/client/assets/index-D3czxq1I.css +2 -0
  3. package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
  4. package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
  5. package/client/index.html +3 -3
  6. package/package.json +6 -5
  7. package/server/agent/attachmentConverter.ts +0 -25
  8. package/server/api/auth/viewToken.ts +10 -8
  9. package/server/api/routes/agent.ts +35 -10
  10. package/server/api/routes/collections.ts +150 -96
  11. package/server/api/routes/files.ts +57 -54
  12. package/server/api/routes/mulmo-script.ts +257 -895
  13. package/server/api/routes/wiki/history.ts +17 -19
  14. package/server/events/collection-change.ts +0 -5
  15. package/server/events/file-change.ts +1 -6
  16. package/server/index.ts +25 -76
  17. package/server/plugins/diagnostics.ts +0 -6
  18. package/server/plugins/mulmoscript-server.ts +58 -0
  19. package/server/prompts/system/system.md +2 -2
  20. package/server/remoteHost/handlers/getCollection.ts +5 -4
  21. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  22. package/server/remoteHost/handlers/index.ts +3 -1
  23. package/server/utils/files/html-store.ts +2 -16
  24. package/server/utils/files/journal-io.ts +0 -12
  25. package/server/utils/files/plugins-io.ts +2 -11
  26. package/server/utils/files/safe.ts +39 -0
  27. package/server/utils/httpError.ts +6 -0
  28. package/server/utils/time.ts +0 -3
  29. package/server/workspace/collections/index.ts +1 -0
  30. package/server/workspace/collections/remoteView.ts +13 -4
  31. package/server/workspace/journal/state.ts +1 -9
  32. package/server/workspace/memory/topic-index-hook.ts +0 -9
  33. package/server/workspace/memory/topic-io.ts +0 -4
  34. package/server/workspace/skills/external/catalog.ts +24 -13
  35. package/server/workspace/skills/writer.ts +20 -16
  36. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  37. package/src/composables/useFileDropZone.ts +3 -15
  38. package/src/config/apiRoutes.ts +6 -0
  39. package/src/config/roles.ts +0 -4
  40. package/src/lang/de.ts +0 -30
  41. package/src/lang/en.ts +0 -30
  42. package/src/lang/es.ts +0 -30
  43. package/src/lang/fr.ts +0 -30
  44. package/src/lang/ja.ts +0 -30
  45. package/src/lang/ko.ts +0 -30
  46. package/src/lang/pt-BR.ts +0 -30
  47. package/src/lang/zh.ts +0 -30
  48. package/src/plugins/accounting/index.ts +0 -2
  49. package/src/plugins/api.ts +0 -6
  50. package/src/plugins/canvas/index.ts +0 -2
  51. package/src/plugins/chart/index.ts +0 -2
  52. package/src/plugins/editImages/index.ts +0 -2
  53. package/src/plugins/generateImage/index.ts +0 -2
  54. package/src/plugins/manageSkills/index.ts +0 -2
  55. package/src/plugins/markdown/index.ts +0 -2
  56. package/src/plugins/photoLocations/index.ts +0 -2
  57. package/src/plugins/presentCollection/index.ts +0 -2
  58. package/src/plugins/presentForm/index.ts +0 -2
  59. package/src/plugins/presentHtml/index.ts +0 -2
  60. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  61. package/src/plugins/presentMulmoScript/index.ts +62 -12
  62. package/src/plugins/presentSVG/index.ts +0 -2
  63. package/src/plugins/skill/index.ts +0 -3
  64. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  65. package/src/plugins/spreadsheet/index.ts +0 -2
  66. package/src/plugins/textResponse/index.ts +0 -3
  67. package/src/plugins/wiki/index.ts +0 -2
  68. package/src/types/notification.ts +0 -10
  69. package/src/utils/api.ts +0 -4
  70. package/client/assets/index-D5nJbMO1.css +0 -2
  71. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  72. package/server/utils/mulmoErrorCapture.ts +0 -93
  73. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  74. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  75. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -1,1949 +0,0 @@
1
- <template>
2
- <div class="h-full bg-white flex flex-col overflow-hidden">
3
- <!-- Header -->
4
- <div class="flex items-start justify-between px-6 py-4 border-b border-gray-100 shrink-0">
5
- <div class="min-w-0 flex-1">
6
- <h2 class="text-lg font-semibold text-gray-800 truncate" data-testid="mulmo-script-title">
7
- {{ script.title || "Untitled Script" }}
8
- </h2>
9
- <p v-if="script.description" class="text-sm text-gray-500 mt-0.5 truncate" data-testid="mulmo-script-description">
10
- {{ script.description }}
11
- </p>
12
- <div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
13
- <span>{{ t("pluginMulmoScript.beatCount", beats.length, { named: { count: beats.length } }) }}</span>
14
- <span v-if="script.lang">{{ script.lang }}</span>
15
- <span v-if="filePath" class="truncate">{{ filePath }}</span>
16
- </div>
17
- </div>
18
- <div class="ml-4 shrink-0 flex items-center gap-2">
19
- <!-- Play presentation: opens the lightbox at beat 0 and starts
20
- audio. Same gating as Download Movie — only when a movie has
21
- been generated, which is our proxy for "every beat has both
22
- an image and audio on disk". Green outline + green icon
23
- share the visual idiom with the (filled) Download button so
24
- both completed-artifact actions read as the same family.
25
- `isPlayReady` ensures we don't open the lightbox before the
26
- first beat's image (and audio, if it has text) finish their
27
- async load — moviePath can be set while loadExistingBeatImage
28
- is still in flight. -->
29
- <button
30
- v-if="moviePath && !movieGenerating"
31
- class="h-8 w-8 flex items-center justify-center rounded border border-green-600 text-green-600 hover:bg-green-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
32
- :disabled="!isPlayReady"
33
- :title="t('pluginMulmoScript.playPresentation')"
34
- :aria-label="t('pluginMulmoScript.playPresentation')"
35
- @click="playPresentation"
36
- >
37
- <span class="material-icons text-base">play_arrow</span>
38
- </button>
39
- <!-- Download Movie: bearer-authenticated blob fetch, then a
40
- synthetic <a download> click. The natural <a href download>
41
- approach can't attach the Authorization header, which would
42
- have forced a bearer-auth exemption on the route — the
43
- reviewer's P1 was that any sibling process could then read
44
- a caller-controlled movie path. Going through apiFetchRaw
45
- (auto-attaches bearer) keeps the auth boundary intact. -->
46
- <button
47
- v-if="moviePath && !movieGenerating"
48
- class="h-8 px-2.5 flex items-center gap-1 rounded bg-green-600 hover:bg-green-700 text-white text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
49
- :disabled="movieDownloading"
50
- data-testid="mulmo-script-download-movie-button"
51
- @click="downloadMovie"
52
- >
53
- <span class="material-icons text-base">download</span>
54
- <span>{{ t("pluginMulmoScript.movie") }}</span>
55
- </button>
56
- <!-- Regenerate Movie (icon-only): collapses to a square once a
57
- movie exists — the adjacent Download / Play already make
58
- the subject clear, so the "Movie" label only adds noise. -->
59
- <button
60
- v-if="moviePath && !movieGenerating"
61
- class="h-8 w-8 flex items-center justify-center rounded border border-gray-200 text-gray-600 hover:bg-gray-100 transition-colors"
62
- :title="t('pluginMulmoScript.regenerateMovie')"
63
- :aria-label="t('pluginMulmoScript.regenerateMovie')"
64
- data-testid="mulmo-script-regenerate-movie-button"
65
- @click="generateMovie"
66
- >
67
- <span class="material-icons text-base">refresh</span>
68
- </button>
69
- <!-- Generate Movie (pill): no movie yet, or one is currently
70
- generating. Keeps the label so first-time users know what
71
- they're triggering. -->
72
- <button
73
- v-else
74
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
75
- :disabled="movieGenerating"
76
- data-testid="mulmo-script-generate-movie-button"
77
- @click="generateMovie"
78
- >
79
- <svg v-if="movieGenerating" class="animate-spin w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none">
80
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
81
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
82
- </svg>
83
- <span v-if="movieGenerating">{{ t("pluginMulmoScript.generating") }}</span>
84
- <template v-else>
85
- <span class="material-icons text-sm">refresh</span>
86
- <span>{{ t("pluginMulmoScript.movie") }}</span>
87
- </template>
88
- </button>
89
- <!-- PDF (#1614): same Generate / Download / Regenerate pattern
90
- as the Movie cluster above, kept structurally separate so
91
- the two outputs can be requested independently and report
92
- status independently. -->
93
- <button
94
- v-if="pdfPath && !pdfGenerating"
95
- class="h-8 px-2.5 flex items-center gap-1 rounded bg-red-600 hover:bg-red-700 text-white text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
96
- :disabled="pdfDownloading"
97
- data-testid="mulmo-script-download-pdf-button"
98
- @click="downloadPdf"
99
- >
100
- <span class="material-icons text-base">download</span>
101
- <span>{{ t("pluginMulmoScript.pdf") }}</span>
102
- </button>
103
- <button
104
- v-if="pdfPath && !pdfGenerating"
105
- class="h-8 w-8 flex items-center justify-center rounded border border-gray-200 text-gray-600 hover:bg-gray-100 transition-colors"
106
- :title="t('pluginMulmoScript.regeneratePdf')"
107
- :aria-label="t('pluginMulmoScript.regeneratePdf')"
108
- data-testid="mulmo-script-regenerate-pdf-button"
109
- @click="generatePdf"
110
- >
111
- <span class="material-icons text-base">refresh</span>
112
- </button>
113
- <button
114
- v-else
115
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
116
- :disabled="pdfGenerating"
117
- data-testid="mulmo-script-generate-pdf-button"
118
- @click="generatePdf"
119
- >
120
- <svg v-if="pdfGenerating" class="animate-spin w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none">
121
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
122
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
123
- </svg>
124
- <span v-if="pdfGenerating">{{ t("pluginMulmoScript.generatingPdf") }}</span>
125
- <template v-else>
126
- <span class="material-icons text-sm">picture_as_pdf</span>
127
- <span>{{ t("pluginMulmoScript.pdf") }}</span>
128
- </template>
129
- </button>
130
- </div>
131
- </div>
132
-
133
- <!--
134
- Inline error chip for movie-generation failures (#1197).
135
- Previously the catch arm of `generateMovie` raised an `alert()` —
136
- blocking, no retry path, and many users just dismissed the modal
137
- and saw a stalled spinner with no explanation. The chip stays
138
- visible until the next generate attempt clears it.
139
- -->
140
- <div
141
- v-if="movieError"
142
- data-testid="mulmo-script-movie-error-chip"
143
- class="bg-red-50 border border-red-200 text-red-800 text-xs px-3 py-2 mx-4 mt-3 mb-1 rounded flex items-start gap-2"
144
- >
145
- <span class="material-icons text-base shrink-0 mt-px">error_outline</span>
146
- <div class="flex-1 min-w-0">
147
- <div class="font-medium">{{ t("pluginMulmoScript.movieGenerationFailed") }}</div>
148
- <div class="break-words whitespace-pre-wrap mt-0.5">{{ movieError }}</div>
149
- </div>
150
- <button
151
- class="shrink-0 h-7 px-2 text-xs rounded border border-red-300 text-red-700 hover:bg-red-100 disabled:opacity-50"
152
- :disabled="movieGenerating"
153
- data-testid="mulmo-script-movie-retry-button"
154
- @click="generateMovie"
155
- >
156
- {{ t("pluginMulmoScript.retry") }}
157
- </button>
158
- </div>
159
-
160
- <!-- Characters section -->
161
- <div v-if="characterKeys.length > 0" class="border-b border-gray-100 shrink-0 px-4 py-3">
162
- <div class="flex items-center justify-between mb-2">
163
- <span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">{{ t("pluginMulmoScript.characters") }}</span>
164
- <button
165
- class="px-2 py-0.5 text-xs rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
166
- :disabled="movieGenerating || anyBeatRendering || characterKeys.every((key) => charRenderState[key] === 'rendering')"
167
- @click="generateAllCharacters"
168
- >
169
- {{ t("pluginMulmoScript.generateAll") }}
170
- </button>
171
- </div>
172
- <div class="flex gap-3 flex-wrap">
173
- <div v-for="key in characterKeys" :key="key" class="flex flex-col items-center gap-1 w-36">
174
- <!-- Character thumbnail -->
175
- <div
176
- class="relative w-36 h-36 rounded-lg border overflow-hidden bg-gray-50 flex items-center justify-center transition-colors"
177
- :class="charDragOver[key] ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
178
- @dragover="onCharDragOver($event, key)"
179
- @dragleave="onCharDragLeave(key)"
180
- @drop="onCharDrop($event, key)"
181
- >
182
- <img
183
- v-if="charImages[key]"
184
- :src="charImages[key]"
185
- class="w-full h-full object-cover cursor-zoom-in"
186
- :alt="key"
187
- @click="openCharacterLightbox(key)"
188
- />
189
- <template v-else-if="charRenderState[key] === 'rendering'">
190
- <svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
191
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
192
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
193
- </svg>
194
- </template>
195
- <template v-else-if="charRenderState[key] === 'error'">
196
- <span class="text-xs text-red-400 text-center px-1">{{ charErrors[key] }}</span>
197
- </template>
198
- <template v-else>
199
- <span class="text-xs text-gray-300 text-center px-1 leading-tight">{{ characterPrompt(key) }}</span>
200
- </template>
201
- <!-- Permanent drop hint -->
202
- <div v-if="!charDragOver[key]" class="absolute bottom-0 inset-x-0 text-center text-xs text-gray-400 bg-white/70 py-0.5 pointer-events-none">
203
- {{ t("pluginMulmoScript.orDropImage") }}
204
- </div>
205
- <!-- Drop overlay -->
206
- <div v-if="charDragOver[key]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
207
- <span class="text-xs text-blue-500 font-medium">{{ t("pluginMulmoScript.drop") }}</span>
208
- </div>
209
- <!-- Regenerate button -->
210
- <button
211
- v-if="charImages[key] && charRenderState[key] !== 'rendering'"
212
- class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
213
- :class="
214
- movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-gray-400 text-gray-600 hover:bg-gray-50'
215
- "
216
- :disabled="movieGenerating || anyBeatRendering"
217
- @click.stop="renderCharacter(key, true)"
218
- >
219
- <span v-if="movieGenerating || anyBeatRendering" class="inline-block animate-spin">↺</span>
220
- <span v-else>↺</span>
221
- </button>
222
- <!-- Generate button -->
223
- <button
224
- v-else-if="!charImages[key] && charRenderState[key] !== 'rendering'"
225
- class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
226
- :class="
227
- movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-blue-400 text-blue-600 hover:bg-blue-50'
228
- "
229
- :disabled="movieGenerating || anyBeatRendering"
230
- @click.stop="renderCharacter(key, false)"
231
- >
232
- <svg v-if="movieGenerating || anyBeatRendering" class="animate-spin w-3 h-3" viewBox="0 0 24 24" fill="none">
233
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
234
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
235
- </svg>
236
- <span v-else>{{ t("pluginMulmoScript.gen") }}</span>
237
- </button>
238
- </div>
239
- <span class="text-xs text-gray-600 text-center truncate w-full">{{ key }}</span>
240
- </div>
241
- </div>
242
- </div>
243
-
244
- <!-- Deck editor (#1575): every beat is a slide → mount the
245
- interactive deck editor from @mulmocast/deck-web. The Vue
246
- component is lazy-loaded via defineAsyncComponent, so users
247
- whose scripts aren't pure decks never pay the bundle cost. -->
248
- <div v-if="isDeck" class="flex-1 overflow-hidden" data-testid="mulmo-script-deck-editor">
249
- <MulmoScriptDeckEditor :script="deckScriptInput" layout="compact" @update:script="onDeckUpdate" />
250
- </div>
251
-
252
- <!-- Beat list (fallback when the script has any non-slide beat) -->
253
- <div v-else ref="beatListEl" class="flex-1 overflow-y-auto p-2 space-y-1.5">
254
- <div v-for="(beat, index) in beats" :key="index" class="rounded-lg border border-gray-200 overflow-hidden">
255
- <!-- Beat body: thumbnail + narration side by side -->
256
- <div class="flex gap-3 items-stretch">
257
- <!-- Thumbnail -->
258
- <div
259
- class="relative shrink-0 w-[45%] overflow-hidden bg-gray-50 transition-colors"
260
- :class="beatDragOver[index] ? 'bg-blue-50' : ''"
261
- @dragover="onBeatDragOver($event, index)"
262
- @dragleave="onBeatDragLeave(index)"
263
- @drop="onBeatDrop($event, index)"
264
- >
265
- <!-- Inline player for the beat's generated video clip.
266
- Replaces the thumbnail while open; the close button
267
- returns to the still image. -->
268
- <template v-if="beatMovieOpen[index] && beatMovieUrls[index]">
269
- <video :src="beatMovieUrls[index]" class="w-full object-contain" controls autoplay :data-testid="`mulmo-script-beat-movie-player-${index}`" />
270
- <button
271
- class="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded border border-gray-400 text-gray-600 bg-white hover:bg-gray-50"
272
- :title="t('common.close')"
273
- :aria-label="t('common.close')"
274
- :data-testid="`mulmo-script-beat-movie-close-${index}`"
275
- @click.stop="closeBeatMovie(index)"
276
- >
277
- <span class="material-icons text-sm">close</span>
278
- </button>
279
- </template>
280
- <template v-else>
281
- <img
282
- v-if="renderedImages[index]"
283
- :src="renderedImages[index]"
284
- class="w-full object-contain cursor-zoom-in"
285
- :alt="`Beat ${index + 1}`"
286
- @click="openLightbox(index)"
287
- />
288
- <!-- Play overlay: shown when the beat-movie probe found a
289
- generated clip for this beat. Blob is fetched lazily
290
- on first click (bearer-auth), hence the spinner. -->
291
- <button
292
- v-if="renderedImages[index] && beatMovies[index]"
293
- class="absolute inset-0 m-auto w-12 h-12 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70"
294
- :title="t('pluginMulmoScript.play')"
295
- :aria-label="t('pluginMulmoScript.play')"
296
- :data-testid="`mulmo-script-beat-movie-play-${index}`"
297
- @click.stop="playBeatMovie(index)"
298
- >
299
- <svg v-if="beatMovieLoading[index]" class="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
300
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
301
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
302
- </svg>
303
- <span v-else class="material-icons text-3xl">play_arrow</span>
304
- </button>
305
- <button
306
- v-if="renderedImages[index] && renderState[index] !== 'rendering'"
307
- class="absolute top-1.5 right-1.5 flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-gray-400 text-gray-600 bg-white hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
308
- :disabled="movieGenerating"
309
- @click.stop="regenerateBeat(index)"
310
- >
311
-
312
- </button>
313
- <div v-else-if="!renderedImages[index]" class="w-full aspect-video flex flex-col items-center justify-center gap-1 p-2">
314
- <template v-if="renderState[index] === 'rendering' || (movieGenerating && !renderedImages[index] && effectiveBeat(index).imagePrompt)">
315
- <svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
316
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
317
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
318
- </svg>
319
- <span class="text-xs text-green-500">{{ t("pluginMulmoScript.rendering") }}</span>
320
- </template>
321
- <template v-else-if="renderState[index] === 'error'">
322
- <span class="text-xs text-red-400 text-center">{{ renderErrors[index] }}</span>
323
- </template>
324
- <template v-else>
325
- <span v-if="effectiveBeat(index).imagePrompt" class="text-xs text-gray-400 text-center italic leading-relaxed px-1">{{
326
- effectiveBeat(index).imagePrompt
327
- }}</span>
328
- <span v-else class="text-xs text-gray-300">{{ beat.image?.type ?? "—" }}</span>
329
- </template>
330
- </div>
331
- </template>
332
- <!-- Beat drop hint / overlay -->
333
- <div v-if="beatDragOver[index]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
334
- <span class="text-xs text-blue-500 font-medium">{{ t("pluginMulmoScript.drop") }}</span>
335
- </div>
336
- <div
337
- v-else-if="!renderedImages[index] && renderState[index] !== 'rendering'"
338
- class="absolute bottom-0 inset-x-0 text-center text-xs text-gray-400 bg-white/70 py-0.5 pointer-events-none"
339
- >
340
- {{ t("pluginMulmoScript.orDropImage") }}
341
- </div>
342
- <!-- Generate button for any beat without a rendered image.
343
- renderBeat works for every beat type: imagePrompt /
344
- typed image beats render directly, moviePrompt beats
345
- get a frame extracted from the generated clip, and
346
- text-only beats fall back to a prompt derived from
347
- the narration text (mulmocast prompt.js). -->
348
- <button
349
- v-if="!renderedImages[index] && renderState[index] !== 'rendering' && !movieGenerating"
350
- class="absolute top-1.5 right-1.5 flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-blue-400 text-blue-600 bg-white hover:bg-blue-50"
351
- @click="renderBeat(index)"
352
- >
353
- {{ t("pluginMulmoScript.generate") }}
354
- </button>
355
- </div>
356
-
357
- <!-- Narration text -->
358
- <div class="flex flex-col flex-1 min-w-0 px-2 py-1.5">
359
- <span class="text-sm text-gray-800 leading-relaxed">{{ effectiveBeat(index).text }}</span>
360
- <div class="flex justify-between mt-auto pt-1">
361
- <!-- Audio controls -->
362
- <div class="flex items-center gap-1">
363
- <template v-if="audioState[index] === 'generating' || (movieGenerating && !beatAudios[index] && effectiveBeat(index).text)">
364
- <svg class="animate-spin w-3 h-3 text-green-400" viewBox="0 0 24 24" fill="none">
365
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
366
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
367
- </svg>
368
- </template>
369
- <button
370
- v-else-if="beatAudios[index]"
371
- class="text-xs px-2 py-0.5 rounded border"
372
- :class="playingAudio?.index === index ? 'border-red-400 text-red-600 hover:bg-red-50' : 'border-green-400 text-green-600 hover:bg-green-50'"
373
- @click="playAudio(index)"
374
- >
375
- {{ playingAudio?.index === index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
376
- </button>
377
- <template v-else-if="audioErrors[index]">
378
- <span class="text-xs text-red-400 truncate min-w-0 max-w-[20rem]" :title="audioErrors[index]">
379
- {{ t("pluginMulmoScript.errPrefix") }} {{ audioErrors[index] }}
380
- </span>
381
- <button
382
- v-if="effectiveBeat(index).text"
383
- class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
384
- :disabled="movieGenerating"
385
- @click="generateAudio(index)"
386
- >
387
-
388
- </button>
389
- </template>
390
- <button
391
- v-else-if="effectiveBeat(index).text"
392
- class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50"
393
- @click="generateAudio(index)"
394
- >
395
- {{ t("pluginMulmoScript.generateAudio") }}
396
- </button>
397
- </div>
398
- <button
399
- class="text-gray-400 hover:text-gray-600"
400
- :title="sourceOpen[index] ? 'Hide source' : 'Show source'"
401
- :data-testid="`mulmo-script-beat-source-toggle-${index}`"
402
- @click="toggleSource(index)"
403
- >
404
- <svg
405
- xmlns="http://www.w3.org/2000/svg"
406
- class="w-3.5 h-3.5"
407
- viewBox="0 0 24 24"
408
- fill="none"
409
- stroke="currentColor"
410
- stroke-width="2"
411
- stroke-linecap="round"
412
- stroke-linejoin="round"
413
- >
414
- <polyline points="16 18 22 12 16 6" />
415
- <polyline points="8 6 2 12 8 18" />
416
- </svg>
417
- </button>
418
- </div>
419
- </div>
420
- </div>
421
-
422
- <!-- Source editor -->
423
- <div v-if="sourceOpen[index]" class="border-t border-gray-100">
424
- <textarea
425
- v-model="sourceText[index]"
426
- class="w-full text-xs text-gray-600 bg-gray-50 p-2 font-mono resize-none"
427
- :class="isValidBeat(index) ? 'outline-none' : 'outline outline-2 outline-red-400'"
428
- rows="8"
429
- spellcheck="false"
430
- :data-testid="`mulmo-script-beat-source-textarea-${index}`"
431
- />
432
- <div class="flex items-center justify-end gap-2 px-2 pb-2">
433
- <span v-if="beatSaveErrors[index]" class="text-xs text-red-600" role="alert">{{
434
- t(beatSaveErrors[index].kind === "invalidJson" ? "pluginMulmoScript.saveErrorInvalidJson" : "pluginMulmoScript.saveErrorSaveFailed", {
435
- error: beatSaveErrors[index].error,
436
- })
437
- }}</span>
438
- <button
439
- class="px-2 py-1 text-xs rounded border"
440
- :class="
441
- isValidBeat(index) && !beatSaving[index]
442
- ? 'border-blue-400 text-blue-600 hover:bg-blue-50 cursor-pointer'
443
- : 'border-gray-200 text-gray-300 cursor-not-allowed'
444
- "
445
- :disabled="!isValidBeat(index) || !!beatSaving[index]"
446
- :data-testid="`mulmo-script-beat-update-button-${index}`"
447
- @click="updateBeat(index)"
448
- >
449
- {{ beatSaving[index] ? t("pluginMulmoScript.saving") : t("pluginMulmoScript.update") }}
450
- </button>
451
- </div>
452
- </div>
453
- </div>
454
-
455
- <div v-if="beats.length === 0" class="flex items-center justify-center h-32 text-gray-400 text-sm">{{ t("pluginMulmoScript.noBeats") }}</div>
456
- </div>
457
-
458
- <!-- Bottom bar: Edit Script Source + Copy -->
459
- <div class="bottom-bar-wrapper">
460
- <details ref="sourceDetails" class="script-source" @toggle="onSourceToggle(($event.target as HTMLDetailsElement).open)">
461
- <summary>{{ t("pluginMulmoScript.editSource") }}</summary>
462
- <textarea
463
- v-model="editableSource"
464
- class="script-editor"
465
- :class="{ 'script-editor-invalid': sourceChanged && !sourceValid }"
466
- spellcheck="false"
467
- ></textarea>
468
- <div class="editor-actions">
469
- <button class="apply-btn" :disabled="!sourceChanged || !sourceValid" @click="applySource">{{ t("pluginMulmoScript.applyChanges") }}</button>
470
- <button class="cancel-btn" @click="cancelSourceEdit">{{ t("common.cancel") }}</button>
471
- </div>
472
- </details>
473
- <button v-show="!editing" class="copy-btn" :title="copied ? 'Copied!' : 'Copy'" @click="copyText">
474
- <span class="material-icons">{{ copied ? "check" : "content_copy" }}</span>
475
- </button>
476
- </div>
477
-
478
- <!-- Lightbox -->
479
- <div v-if="lightbox" class="fixed inset-0 z-50 bg-black/80 overflow-y-auto" @click="closeLightbox">
480
- <button class="fixed top-2 right-4 z-10 text-white/60 hover:text-white text-3xl leading-none" :title="t('common.close')" @click.stop="closeLightbox">
481
-
482
- </button>
483
- <div class="flex flex-col items-center gap-4 pt-4 pb-8" @click.stop>
484
- <div class="flex items-center gap-4">
485
- <button
486
- v-if="!lightbox.isCharacter"
487
- class="text-white/60 hover:text-white disabled:opacity-20 text-5xl leading-none"
488
- :disabled="!hasPrev"
489
- @click="lightboxMove(-1)"
490
- >
491
-
492
- </button>
493
- <div class="flex flex-col items-center">
494
- <img :src="lightbox.src" class="max-w-[80vw] max-h-[85vh] object-contain rounded shadow-2xl" />
495
- <div v-if="!lightbox.isCharacter && beats.length > 1" class="relative w-full h-1">
496
- <div class="flex gap-1 h-full">
497
- <div
498
- v-for="i in beats.length"
499
- :key="i - 1"
500
- class="group flex-1 cursor-pointer relative transition-colors"
501
- :class="
502
- i - 1 === lightbox.index
503
- ? 'bg-white/80 hover:bg-white'
504
- : i - 1 < lightbox.index
505
- ? 'bg-white/40 hover:bg-white/60'
506
- : 'bg-white/20 hover:bg-white/40'
507
- "
508
- @click="jumpToBeat(i - 1)"
509
- >
510
- <span class="absolute -inset-y-3 inset-x-0" />
511
- <div
512
- v-if="beatTooltip(i - 1)"
513
- class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-20 px-2 py-1 rounded bg-black/90 text-white text-xs leading-tight w-48 max-h-[53px] overflow-hidden opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity"
514
- >
515
- {{ beatTooltip(i - 1) }}
516
- </div>
517
- </div>
518
- </div>
519
- <div
520
- v-if="playingAudio && playingAudio.index === lightbox.index"
521
- class="absolute top-1/2 w-3.5 h-3.5 rounded-full bg-white shadow ring-2 ring-black/30 -translate-y-1/2 -translate-x-1/2 pointer-events-none"
522
- :style="{ left: `${((lightbox.index + audioProgress) / beats.length) * 100}%` }"
523
- />
524
- </div>
525
- </div>
526
- <button
527
- v-if="!lightbox.isCharacter"
528
- class="text-white/60 hover:text-white disabled:opacity-20 text-5xl leading-none"
529
- :disabled="!hasNext"
530
- @click="lightboxMove(1)"
531
- >
532
-
533
- </button>
534
- </div>
535
- <div v-if="lightbox.text || beatAudios[lightbox.index]" class="relative w-screen flex justify-center px-16">
536
- <p v-if="lightbox.text" class="max-w-[80vw] text-center text-white leading-relaxed text-[clamp(0.8rem,1.76vw,1.6rem)]">
537
- {{ lightbox.text }}
538
- </p>
539
- <button
540
- v-if="beatAudios[lightbox.index]"
541
- class="absolute top-0 right-4 text-sm px-3 py-1 rounded border border-white/60 text-white/60 hover:bg-white/20"
542
- @click="playAudio(lightbox.index)"
543
- >
544
- {{ playingAudio?.index === lightbox.index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
545
- </button>
546
- </div>
547
- </div>
548
- </div>
549
- </div>
550
- </template>
551
-
552
- <script setup lang="ts">
553
- import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
554
- import { useI18n } from "vue-i18n";
555
- import type { ToolResultComplete } from "gui-chat-protocol/vue";
556
- import type { MulmoScriptData } from "./index";
557
- import { mulmoBeatSchema, mulmoScriptSchema } from "@mulmocast/types";
558
- import {
559
- extractErrorMessage,
560
- getMissingCharacterKeys,
561
- isAllSlideDeck,
562
- isSameScript,
563
- beatMayHaveMovie,
564
- shouldAutoRenderBeat,
565
- streamMovieEvents,
566
- validateBeatJSON,
567
- } from "./helpers";
568
- import { apiGet, apiPost, apiFetchRaw } from "../../utils/api";
569
- import { pluginEndpoints } from "../api";
570
- import type { MulmoScriptEndpoints } from "./definition";
571
- import { errorMessage } from "../../utils/errors";
572
- import { useClipboardCopy } from "../../composables/useClipboardCopy";
573
- import { useActiveSession } from "../../composables/useActiveSession";
574
- import { GENERATION_KINDS, type PendingGeneration } from "../../types/events";
575
- import type { SlideLayout, SlideTheme } from "@mulmocast/deck-web";
576
-
577
- // Lazy-loaded so the deck editor's Vue / tailwind / SlidePreview chunk
578
- // stays out of the initial bundle for users whose scripts aren't decks
579
- // (movies, html_tailwind animations, mixed beats). `defineAsyncComponent`
580
- // triggers the dynamic import only when `isDeck` first flips true.
581
- const MulmoScriptDeckEditor = defineAsyncComponent(() => import("@mulmocast/deck-web").then((mod) => mod.MulmoScriptDeckEditor));
582
-
583
- const endpoints = pluginEndpoints<MulmoScriptEndpoints>("mulmoScript");
584
-
585
- const { t } = useI18n();
586
-
587
- interface Beat {
588
- speaker?: string;
589
- text?: string;
590
- id?: string;
591
- imagePrompt?: string;
592
- moviePrompt?: string;
593
- image?: { type: string; [key: string]: unknown };
594
- /** Beat duration in seconds. The mulmocast schema notes this is
595
- * "Used only when the text is empty" — when there's no TTS audio
596
- * to drive playback, the Play loop uses this as the auto-advance
597
- * timer (#1073). */
598
- duration?: number;
599
- }
600
-
601
- interface ImageEntry {
602
- type: string;
603
- prompt?: string;
604
- [key: string]: unknown;
605
- }
606
-
607
- interface MulmoScript {
608
- title?: string;
609
- description?: string;
610
- lang?: string;
611
- beats?: Beat[];
612
- imageParams?: {
613
- images?: Record<string, ImageEntry>;
614
- [key: string]: unknown;
615
- };
616
- [key: string]: unknown;
617
- }
618
-
619
- const props = defineProps<{
620
- selectedResult: ToolResultComplete<MulmoScriptData>;
621
- }>();
622
- const emit = defineEmits<{ updateResult: [result: ToolResultComplete] }>();
623
-
624
- const data = computed(() => props.selectedResult.data);
625
- const script = computed<MulmoScript>(() => data.value?.script ?? {});
626
- const filePath = computed(() => data.value?.filePath ?? "");
627
- const beats = computed<Beat[]>(() => script.value.beats ?? []);
628
-
629
- // Per-beat render state
630
- type RenderState = "idle" | "rendering" | "done" | "error";
631
- const renderState = reactive<Record<number, RenderState>>({});
632
- const renderedImages = reactive<Record<number, string>>({});
633
- const renderErrors = reactive<Record<number, string>>({});
634
- const sourceOpen = reactive<Record<number, boolean>>({});
635
- const sourceText = reactive<Record<number, string>>({});
636
- // Surface POST /api/mulmo-script/update-beat failures inline next to
637
- // the Update button. Cleared on next successful save or editor close.
638
- // Store raw error + kind tag so the template picks a localized key,
639
- // instead of pre-composing an English-prefixed string here.
640
- interface BeatSaveError {
641
- kind: "invalidJson" | "saveFailed";
642
- error: string;
643
- }
644
- const beatSaveErrors = reactive<Record<number, BeatSaveError>>({});
645
- const beatSaving = reactive<Record<number, boolean>>({});
646
- const localOverrides = reactive<Record<number, Beat>>({});
647
- const movieGenerating = ref(false);
648
- const movieDownloading = ref(false);
649
- const moviePath = ref<string | null>(null);
650
- // Persists the most-recent movie-generation failure so the spinner
651
- // area can surface it inline with a retry button (#1197). Cleared
652
- // at the start of every generate / regenerate attempt.
653
- const movieError = ref<string | null>(null);
654
- // PDF generation (#1614). Mirrors the movie triple — path / spinner /
655
- // downloading flag — kept independent so a PDF and a movie can be
656
- // generated for the same script without state collision.
657
- const pdfGenerating = ref(false);
658
- const pdfDownloading = ref(false);
659
- const pdfPath = ref<string | null>(null);
660
- const beatAudios = reactive<Record<number, string>>({});
661
- const audioState = reactive<Record<number, "generating" | "done" | "error">>({});
662
- const audioErrors = reactive<Record<number, string>>({});
663
- // Per-beat generated video clip (moviePrompt / animated beats).
664
- // `beatMovies` holds the "stories/…" wire path from the beat-movie
665
- // probe; the blob object URL is fetched lazily on first play through
666
- // the bearer-authenticated downloadMovie route — a plain <video src>
667
- // can't attach the Authorization header.
668
- const beatMovies = reactive<Record<number, string>>({});
669
- const beatMovieUrls = reactive<Record<number, string>>({});
670
- const beatMovieOpen = reactive<Record<number, boolean>>({});
671
- const beatMovieLoading = reactive<Record<number, boolean>>({});
672
- const playingAudio = ref<{ index: number; audio: HTMLAudioElement } | null>(null);
673
- // Tracks the auto-advance timer running on a silent beat
674
- // (`beat.text === ""`). Beats without text generate no audio, so the
675
- // Play loop falls back to a `setTimeout(beat.duration)` for cues —
676
- // without this, Play would stall on the first silent beat (#1073).
677
- const silentPlaybackTimer = ref<{ index: number; timer: ReturnType<typeof setTimeout> } | null>(null);
678
- const audioProgress = ref(0);
679
-
680
- // Default duration (seconds) for a silent beat whose script doesn't
681
- // set `duration` either. Picked to roughly match the time it takes a
682
- // reader to scan a `textSlide` — long enough to read, short enough
683
- // not to feel stuck. The script's own `duration` always wins.
684
- const SILENT_BEAT_DEFAULT_SEC = 3;
685
- const MS_PER_SECOND = 1000;
686
- const beatListEl = ref<HTMLElement | null>(null);
687
- const lightbox = ref<{
688
- src: string;
689
- text?: string;
690
- index: number;
691
- isCharacter?: boolean;
692
- } | null>(null);
693
- // Character (imageParams.images) state
694
- type CharRenderState = "idle" | "rendering" | "done" | "error";
695
- const charRenderState = reactive<Record<string, CharRenderState>>({});
696
- const charImages = reactive<Record<string, string>>({});
697
- const charErrors = reactive<Record<string, string>>({});
698
- const charDragOver = reactive<Record<string, boolean>>({});
699
- const beatDragOver = reactive<Record<number, boolean>>({});
700
-
701
- const anyBeatRendering = computed(() => Object.values(renderState).some((state) => state === "rendering"));
702
-
703
- const characterKeys = computed(() => {
704
- const imgs = script.value.imageParams?.images ?? {};
705
- return Object.keys(imgs).filter((key) => imgs[key]?.type === "imagePrompt");
706
- });
707
-
708
- // Session-scoped pending generations — lets spinners survive view
709
- // unmount/remount and tags new generations on the correct session
710
- // channel so the cross-session sidebar indicator stays lit.
711
- const activeSessionRef = useActiveSession();
712
- const chatSessionId = computed(() => activeSessionRef?.value?.id);
713
-
714
- const pendingForThisScript = computed(() => {
715
- const out: Record<string, PendingGeneration> = {};
716
- const pending = activeSessionRef?.value?.pendingGenerations ?? {};
717
- const currentPath = filePath.value;
718
- if (!currentPath) return out;
719
- for (const [mapKey, entry] of Object.entries(pending)) {
720
- if (entry.filePath === currentPath) out[mapKey] = entry;
721
- }
722
- return out;
723
- });
724
-
725
- // Local renderState / charRenderState / audioState / movieGenerating
726
- // are kept in sync with `pendingForThisScript` by the watcher below
727
- // and by `initializeScript`, so the template continues to read them
728
- // without needing per-kind predicates here.
729
-
730
- function characterPrompt(key: string): string {
731
- return (script.value.imageParams?.images?.[key]?.prompt as string) ?? "";
732
- }
733
-
734
- function stopPlayingAudio() {
735
- // Single helper that clears both the audio path and the silent
736
- // auto-advance timer — callers (lightbox open / arrow nav / Stop
737
- // button) get consistent behaviour without remembering which
738
- // playback mode the current beat was using (#1073).
739
- stopAllPlayback();
740
- }
741
-
742
- function openLightbox(index: number) {
743
- stopPlayingAudio();
744
- lightbox.value = {
745
- src: renderedImages[index],
746
- text: effectiveBeat(index).text,
747
- index,
748
- };
749
- }
750
-
751
- // Backdrop click handler. Stops any in-flight narration so the audio
752
- // doesn't keep playing after the lightbox is dismissed — without this,
753
- // the HTMLAudioElement created by playAudio() outlives the modal and
754
- // the user hears disembodied narration with no UI to stop it.
755
- function closeLightbox() {
756
- stopPlayingAudio();
757
- lightbox.value = null;
758
- }
759
-
760
- // "Play presentation" toolbar action. Opens the lightbox at beat 0 and
761
- // kicks off its narration audio; the existing on-ended hook then chains
762
- // through the rest of the deck (lightboxMove(1) → playAudio if the next
763
- // beat has audio), so one click runs the whole presentation. Only wired
764
- // to the toolbar button when moviePath is set, which is our proxy for
765
- // "every beat has both image and audio on disk".
766
- //
767
- // `moviePath` arrives synchronously from /movie-status, but the per-beat
768
- // image and audio data URIs are populated asynchronously by
769
- // loadExistingBeatImage / loadExistingBeatAudio in initializeScript().
770
- // The Play button can therefore become visible before beat 0's assets
771
- // hydrate — `isPlayReady` gates the click so the lightbox never opens
772
- // with an undefined src or silent narration on a beat that does have
773
- // text.
774
- const isPlayReady = computed<boolean>(() => {
775
- if (beats.value.length === 0) return false;
776
- if (!renderedImages[0]) return false;
777
- // Audio is only required when the beat has text (the source of TTS).
778
- // Beats without text are valid; they just play silently.
779
- if (effectiveBeat(0).text && !beatAudios[0]) return false;
780
- return true;
781
- });
782
-
783
- function playPresentation() {
784
- if (!isPlayReady.value) return;
785
- openLightbox(0);
786
- playBeat(0);
787
- }
788
-
789
- // Stop whichever playback handle is active. Idempotent. Called by
790
- // openLightbox, manual stop / pause buttons, and by `playBeat`
791
- // before kicking off a new beat so we never double-schedule. (#1073)
792
- function stopAllPlayback(): void {
793
- if (playingAudio.value) {
794
- playingAudio.value.audio.pause();
795
- playingAudio.value = null;
796
- audioProgress.value = 0;
797
- }
798
- if (silentPlaybackTimer.value) {
799
- clearTimeout(silentPlaybackTimer.value.timer);
800
- silentPlaybackTimer.value = null;
801
- }
802
- }
803
-
804
- // Single entry point for "start playback at beat <index>". Routes
805
- // on what the script DECLARED, not on what's currently hydrated:
806
- //
807
- // - `text` empty → silent path (`scheduleSilentAdvance`). The
808
- // schema says no audio is generated for empty-text beats, so
809
- // `duration` drives auto-advance.
810
- // - `text` present + audio loaded → audio path. `audio.ended`
811
- // chains via `advanceFromBeat`.
812
- // - `text` present + audio NOT loaded → stop. The Play button's
813
- // `isPlayReady` gate prevented this for beat 0, but mid-stream
814
- // a transient fetch miss must not silently skip the narration
815
- // by falling through to the silent timer (Codex review on
816
- // #1073 — gating on `beatAudios[index]` would do exactly that).
817
- //
818
- // Either path chains to the next beat via `advanceFromBeat`, so a
819
- // run of silent beats — or audio / silent / audio sequences —
820
- // plays through without manual interaction.
821
- function playBeat(index: number): void {
822
- stopAllPlayback();
823
- const hasText = Boolean(effectiveBeat(index).text);
824
- if (!hasText) {
825
- scheduleSilentAdvance(index);
826
- return;
827
- }
828
- if (beatAudios[index]) {
829
- playAudio(index);
830
- }
831
- // Text beat with no audio yet → stop. The user can re-click Play
832
- // once the audio finishes hydrating.
833
- }
834
-
835
- function scheduleSilentAdvance(index: number): void {
836
- // Defensively narrow the script-supplied duration. A bad value
837
- // (zero, negative, NaN, non-number) would otherwise collapse to
838
- // an immediate timeout and the Play loop would race through every
839
- // silent beat in a single tick (Codex review iter-5 on #1365).
840
- // Falling back to the default keeps the presentation watchable.
841
- const raw = effectiveBeat(index).duration;
842
- const seconds = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : SILENT_BEAT_DEFAULT_SEC;
843
- const timer = setTimeout(() => {
844
- if (silentPlaybackTimer.value?.index !== index) return;
845
- silentPlaybackTimer.value = null;
846
- if (lightbox.value?.index === index) advanceFromBeat(index);
847
- }, seconds * MS_PER_SECOND);
848
- silentPlaybackTimer.value = { index, timer };
849
- }
850
-
851
- function advanceFromBeat(fromIndex: number): void {
852
- lightboxMove(1);
853
- const nextIndex = lightbox.value?.index;
854
- if (nextIndex === undefined || nextIndex === fromIndex) return;
855
- playBeat(nextIndex);
856
- }
857
-
858
- const hasPrev = computed(() => {
859
- if (!lightbox.value) return false;
860
- for (let i = lightbox.value.index - 1; i >= 0; i--) {
861
- if (renderedImages[i]) return true;
862
- }
863
- return false;
864
- });
865
-
866
- const hasNext = computed(() => {
867
- if (!lightbox.value) return false;
868
- for (let i = lightbox.value.index + 1; i < beats.value.length; i++) {
869
- if (renderedImages[i]) return true;
870
- }
871
- return false;
872
- });
873
-
874
- function jumpToBeat(index: number) {
875
- if (!lightbox.value) return;
876
- if (index === lightbox.value.index) return;
877
- if (!renderedImages[index]) return;
878
- // Carry the playback mode forward (audio OR silent timer) so a
879
- // user clicking the beat-strip thumbnail mid-playback keeps the
880
- // presentation rolling (#1073).
881
- const wasPlaying = playingAudio.value !== null || silentPlaybackTimer.value !== null;
882
- openLightbox(index);
883
- if (wasPlaying) playBeat(index);
884
- }
885
-
886
- function beatTooltip(index: number): string {
887
- const text = effectiveBeat(index).text ?? "";
888
- return text.length > 80 ? `${text.slice(0, 80)}…` : text;
889
- }
890
-
891
- function lightboxMove(delta: number) {
892
- if (!lightbox.value) return;
893
- const total = beats.value.length;
894
- // If a playback was in progress when the user clicked the arrow,
895
- // carry it forward to whichever beat we land on — `playBeat`
896
- // picks audio vs silent automatically. `openLightbox` stops the
897
- // current playback, so capture the flag BEFORE that and chain
898
- // AFTER. The on-ended / silent-advance paths already null their
899
- // own state before calling `lightboxMove`, so this branch won't
900
- // double-fire there.
901
- const wasPlaying = playingAudio.value !== null || silentPlaybackTimer.value !== null;
902
- let i = lightbox.value.index + delta;
903
- while (i >= 0 && i < total) {
904
- if (renderedImages[i]) {
905
- openLightbox(i);
906
- if (wasPlaying) playBeat(i);
907
- return;
908
- }
909
- i += delta;
910
- }
911
- }
912
- const sourceDetails = ref<HTMLDetailsElement>();
913
- const editing = ref(false);
914
- const editableSource = ref("");
915
- const { copied, copy } = useClipboardCopy();
916
-
917
- // Beats may be edited in-place via `updateBeat()` and rendered through
918
- // `effectiveBeat()`, so the Copy / source-view text must read the merged
919
- // shape — otherwise the clipboard returns the original prop snapshot
920
- // until the full result is reloaded.
921
- const effectiveScript = computed<MulmoScript>(() => ({
922
- ...script.value,
923
- beats: beats.value.map((beat, i) => localOverrides[i] ?? beat),
924
- }));
925
- const scriptSourceText = computed(() => JSON.stringify(effectiveScript.value, null, 2));
926
-
927
- // #1575 — when every beat is a `slide`, swap the per-beat list UI for
928
- // the interactive deck editor (@mulmocast/deck-web). Mixed scripts
929
- // (any non-slide beat) fall back to the existing list so the user can
930
- // keep editing movie / textSlide / html_tailwind beats as before.
931
- const isDeck = computed(() => isAllSlideDeck(effectiveScript.value));
932
-
933
- // `@mulmocast/deck-web` types its `script` prop as a *structural*
934
- // superset of MulmoScript (every key optional + index signature) using
935
- // `SlideLayout` / `SlideTheme` from `@mulmocast/deck`. Our strict
936
- // `MulmoScript` from `@mulmocast/types` doesn't unify with that shape
937
- // by name, so we re-type at the boundary. The cast is safe — any real
938
- // MulmoScript instance fits the structural shape. Mirrored on the way
939
- // out (`onDeckUpdate`).
940
- interface DeckBeatShape {
941
- image?: {
942
- type?: string;
943
- slide?: SlideLayout;
944
- theme?: SlideTheme;
945
- [k: string]: unknown;
946
- };
947
- [k: string]: unknown;
948
- }
949
- interface DeckScriptShape {
950
- beats?: DeckBeatShape[];
951
- presentationStyle?: { slideParams?: { theme?: SlideTheme } };
952
- slideParams?: { theme?: SlideTheme };
953
- [k: string]: unknown;
954
- }
955
- const deckScriptInput = computed<DeckScriptShape>(() => effectiveScript.value as unknown as DeckScriptShape);
956
-
957
- // Debounce window for deck-editor → /update-script. Drag a slide,
958
- // reorder, edit a field — each emit fires `update:script`, and we
959
- // only want one network round-trip per quiet stretch. 300ms is short
960
- // enough to feel live, long enough that typing in the Inspector
961
- // doesn't carpet-bomb the server.
962
- const DECK_SAVE_DEBOUNCE_MS = 300;
963
- let deckSaveTimer: ReturnType<typeof setTimeout> | null = null;
964
- let pendingDeckScript: MulmoScript | null = null;
965
-
966
- function scheduleDeckSave(next: MulmoScript): void {
967
- pendingDeckScript = next;
968
- if (deckSaveTimer) clearTimeout(deckSaveTimer);
969
- deckSaveTimer = setTimeout(() => {
970
- void flushDeckSave();
971
- }, DECK_SAVE_DEBOUNCE_MS);
972
- }
973
-
974
- async function flushDeckSave(): Promise<void> {
975
- deckSaveTimer = null;
976
- const next = pendingDeckScript;
977
- pendingDeckScript = null;
978
- if (!next || !filePath.value) return;
979
- const response = await apiPost<unknown>(endpoints.updateScript.url, {
980
- filePath: filePath.value,
981
- script: next,
982
- });
983
- if (!response.ok) {
984
- // Surface via console so the user can see what failed; a full
985
- // toast UI is P2. The deck editor still holds the latest edit
986
- // in its props until the next prop refresh, so the visible state
987
- // doesn't snap back on a transient failure.
988
- console.error("[presentMulmoScript] deck save failed:", response.error);
989
- return;
990
- }
991
- // Mirror the JSON-source `applySource` flow so the parent's in-memory
992
- // script and our reactive beats[] stay in sync without a remount.
993
- emit("updateResult", {
994
- ...props.selectedResult,
995
- data: { ...props.selectedResult.data, script: next },
996
- });
997
- }
998
-
999
- function onDeckUpdate(next: DeckScriptShape): void {
1000
- scheduleDeckSave(next as unknown as MulmoScript);
1001
- }
1002
-
1003
- onBeforeUnmount(() => {
1004
- if (deckSaveTimer) {
1005
- clearTimeout(deckSaveTimer);
1006
- // Flush synchronously-scheduled work on unmount so a quick switch
1007
- // away doesn't lose the last keystroke. Fire-and-forget — the
1008
- // component is gone, we just want the bytes to land.
1009
- void flushDeckSave();
1010
- }
1011
- // Release beat-clip blob object URLs — they outlive the component
1012
- // otherwise (document-scoped, not GC'd with it).
1013
- resetBeatMovies();
1014
- });
1015
- const loadedSource = ref("");
1016
- const sourceChanged = computed(() => editableSource.value !== loadedSource.value);
1017
- const sourceValid = computed(() => {
1018
- try {
1019
- const parsed = JSON.parse(editableSource.value);
1020
- return mulmoScriptSchema.safeParse(parsed).success;
1021
- } catch {
1022
- return false;
1023
- }
1024
- });
1025
-
1026
- async function onSourceToggle(open: boolean) {
1027
- editing.value = open;
1028
- if (open) {
1029
- let text = scriptSourceText.value;
1030
- // Re-read the current file from disk so beat-level edits made
1031
- // since mount (other tabs, MCP, manual edits) surface in the
1032
- // editor. Uses the reopen endpoint for the same reason
1033
- // refreshScriptFromDisk does — `filePath.value` is the wire form
1034
- // `stories/<rel>` and only `mulmoScript.save` knows how to map
1035
- // it to the on-disk path under `artifacts/stories/...`. The
1036
- // generic `/api/files/content` 404s for that wire form (#1074
1037
- // post-mortem) and silently falls back to in-memory state.
1038
- if (filePath.value) {
1039
- const response = await apiPost<{ data?: { script?: MulmoScript } }>(endpoints.save.url, { filePath: filePath.value });
1040
- const diskScript = response.ok ? response.data?.data?.script : undefined;
1041
- if (diskScript) text = JSON.stringify(diskScript, null, 2);
1042
- // fall through to in-memory script on failure
1043
- }
1044
- editableSource.value = text;
1045
- loadedSource.value = text;
1046
- }
1047
- }
1048
-
1049
- function cancelSourceEdit() {
1050
- if (sourceDetails.value) sourceDetails.value.open = false;
1051
- }
1052
-
1053
- async function applySource() {
1054
- let parsed: MulmoScript;
1055
- try {
1056
- parsed = JSON.parse(editableSource.value);
1057
- } catch (err) {
1058
- alert(extractErrorMessage(err));
1059
- return;
1060
- }
1061
- const response = await apiPost<unknown>(endpoints.updateScript.url, {
1062
- filePath: filePath.value,
1063
- script: parsed,
1064
- });
1065
- if (!response.ok) {
1066
- alert(response.error || "Update failed");
1067
- return;
1068
- }
1069
-
1070
- // Update the UI with the new script.
1071
- // Note: the parent's handleUpdateResult uses Object.assign (in-place
1072
- // mutation), so the watcher on props.selectedResult won't fire.
1073
- // We emit first so the parent data is updated, then manually
1074
- // re-initialize the view.
1075
- emit("updateResult", {
1076
- ...props.selectedResult,
1077
- data: { ...props.selectedResult.data, script: parsed },
1078
- });
1079
-
1080
- if (sourceDetails.value) sourceDetails.value.open = false;
1081
- await initializeScript();
1082
- }
1083
-
1084
- async function copyText() {
1085
- await copy(scriptSourceText.value);
1086
- }
1087
-
1088
- function effectiveBeat(index: number): Beat {
1089
- return localOverrides[index] ?? beats.value[index] ?? {};
1090
- }
1091
-
1092
- function toggleSource(index: number) {
1093
- if (!sourceOpen[index]) {
1094
- sourceText[index] = JSON.stringify(effectiveBeat(index), null, 2);
1095
- Reflect.deleteProperty(beatSaveErrors, index);
1096
- }
1097
- sourceOpen[index] = !sourceOpen[index];
1098
- }
1099
-
1100
- function isValidBeat(index: number): boolean {
1101
- return validateBeatJSON(sourceText[index] ?? "", mulmoBeatSchema);
1102
- }
1103
-
1104
- async function updateBeat(index: number) {
1105
- let beat: Beat;
1106
- try {
1107
- beat = JSON.parse(sourceText[index]);
1108
- } catch (err) {
1109
- beatSaveErrors[index] = { kind: "invalidJson", error: errorMessage(err) };
1110
- return;
1111
- }
1112
- const prevImage = JSON.stringify(effectiveBeat(index).image);
1113
-
1114
- Reflect.deleteProperty(beatSaveErrors, index);
1115
- beatSaving[index] = true;
1116
- const response = await apiPost<unknown>(endpoints.updateBeat.url, {
1117
- filePath: filePath.value,
1118
- beatIndex: index,
1119
- beat,
1120
- });
1121
- Reflect.deleteProperty(beatSaving, index);
1122
- if (!response.ok) {
1123
- beatSaveErrors[index] = { kind: "saveFailed", error: response.error };
1124
- return;
1125
- }
1126
-
1127
- localOverrides[index] = beat;
1128
- sourceOpen[index] = false;
1129
-
1130
- if (JSON.stringify(beat.image) !== prevImage) {
1131
- Reflect.deleteProperty(renderedImages, index);
1132
- renderBeat(index);
1133
- }
1134
- }
1135
-
1136
- async function renderBeat(index: number) {
1137
- renderState[index] = "rendering";
1138
- const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderBeat.url, {
1139
- filePath: filePath.value,
1140
- beatIndex: index,
1141
- chatSessionId: chatSessionId.value,
1142
- });
1143
- if (!response.ok) {
1144
- renderErrors[index] = response.error || "Render failed";
1145
- renderState[index] = "error";
1146
- return;
1147
- }
1148
- if (response.data.error) {
1149
- renderErrors[index] = response.data.error;
1150
- renderState[index] = "error";
1151
- return;
1152
- }
1153
- renderedImages[index] = response.data.image ?? "";
1154
- renderState[index] = "done";
1155
- refreshMissingCharacterImages();
1156
- if (beatMayHaveMovie(effectiveBeat(index))) void loadExistingBeatMovie(index);
1157
- }
1158
-
1159
- async function regenerateBeat(index: number) {
1160
- Reflect.deleteProperty(renderedImages, index);
1161
- invalidateBeatMovie(index);
1162
- renderState[index] = "rendering";
1163
- const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderBeat.url, {
1164
- filePath: filePath.value,
1165
- beatIndex: index,
1166
- force: true,
1167
- chatSessionId: chatSessionId.value,
1168
- });
1169
- if (!response.ok) {
1170
- renderErrors[index] = response.error || "Render failed";
1171
- renderState[index] = "error";
1172
- return;
1173
- }
1174
- if (response.data.error) {
1175
- renderErrors[index] = response.data.error;
1176
- renderState[index] = "error";
1177
- return;
1178
- }
1179
- renderedImages[index] = response.data.image ?? "";
1180
- renderState[index] = "done";
1181
- if (beatMayHaveMovie(effectiveBeat(index))) void loadExistingBeatMovie(index);
1182
- }
1183
-
1184
- async function loadExistingBeatImage(index: number) {
1185
- const response = await apiGet<{ image?: string }>(endpoints.beatImage.url, { filePath: filePath.value, beatIndex: String(index) });
1186
- // silently ignore errors — image simply hasn't been generated yet
1187
- if (response.ok && response.data.image) {
1188
- renderedImages[index] = response.data.image;
1189
- renderState[index] = "done";
1190
- }
1191
- }
1192
-
1193
- async function loadExistingBeatAudio(index: number) {
1194
- const response = await apiGet<{ audio?: string }>(endpoints.beatAudio.url, { filePath: filePath.value, beatIndex: String(index) });
1195
- // silently ignore errors
1196
- if (response.ok && response.data.audio) {
1197
- beatAudios[index] = response.data.audio;
1198
- audioState[index] = "done";
1199
- }
1200
- }
1201
-
1202
- async function loadExistingBeatMovie(index: number) {
1203
- const response = await apiGet<{ moviePath?: string }>(endpoints.beatMovie.url, { filePath: filePath.value, beatIndex: String(index) });
1204
- // silently ignore errors — the clip simply hasn't been generated yet
1205
- if (response.ok && response.data.moviePath) {
1206
- beatMovies[index] = response.data.moviePath;
1207
- }
1208
- }
1209
-
1210
- async function playBeatMovie(index: number) {
1211
- if (!beatMovies[index] || beatMovieLoading[index]) return;
1212
- if (beatMovieUrls[index]) {
1213
- beatMovieOpen[index] = true;
1214
- return;
1215
- }
1216
- beatMovieLoading[index] = true;
1217
- try {
1218
- const res = await apiFetchRaw(endpoints.downloadMovie.url, {
1219
- method: "GET",
1220
- query: { moviePath: beatMovies[index] },
1221
- });
1222
- if (!res.ok) {
1223
- throw new Error(`HTTP ${res.status}`);
1224
- }
1225
- // Re-type the .mov blob as video/mp4 — same ISO-BMFF family, and
1226
- // <video> support for "video/mp4" is broader than "video/quicktime".
1227
- const blob = new Blob([await res.blob()], { type: "video/mp4" });
1228
- beatMovieUrls[index] = URL.createObjectURL(blob);
1229
- beatMovieOpen[index] = true;
1230
- } catch (err) {
1231
- alert(extractErrorMessage(err));
1232
- } finally {
1233
- Reflect.deleteProperty(beatMovieLoading, index);
1234
- }
1235
- }
1236
-
1237
- function closeBeatMovie(index: number) {
1238
- Reflect.deleteProperty(beatMovieOpen, index);
1239
- }
1240
-
1241
- // Drop one beat's cached clip (regenerate is about to replace it on
1242
- // disk). Revoking the object URL frees the blob immediately.
1243
- function invalidateBeatMovie(index: number): void {
1244
- if (beatMovieUrls[index]) URL.revokeObjectURL(beatMovieUrls[index]);
1245
- [beatMovies, beatMovieUrls, beatMovieOpen].forEach((map) => Reflect.deleteProperty(map, index));
1246
- }
1247
-
1248
- function resetBeatMovies(): void {
1249
- Object.values(beatMovieUrls).forEach((url) => URL.revokeObjectURL(url));
1250
- [beatMovies, beatMovieUrls, beatMovieOpen, beatMovieLoading].forEach((map) => {
1251
- Object.keys(map).forEach((key) => Reflect.deleteProperty(map, key));
1252
- });
1253
- }
1254
-
1255
- async function generateAudio(index: number) {
1256
- audioState[index] = "generating";
1257
- Reflect.deleteProperty(audioErrors, index);
1258
- const response = await apiPost<{ audio?: string; error?: string }>(endpoints.generateBeatAudio.url, {
1259
- filePath: filePath.value,
1260
- beatIndex: index,
1261
- chatSessionId: chatSessionId.value,
1262
- });
1263
- if (!response.ok) {
1264
- audioErrors[index] = response.error || "Audio generation failed";
1265
- audioState[index] = "error";
1266
- return;
1267
- }
1268
- if (response.data.error) {
1269
- audioErrors[index] = response.data.error;
1270
- audioState[index] = "error";
1271
- return;
1272
- }
1273
- beatAudios[index] = response.data.audio ?? "";
1274
- audioState[index] = "done";
1275
- }
1276
-
1277
- function playAudio(index: number) {
1278
- if (playingAudio.value) {
1279
- playingAudio.value.audio.pause();
1280
- const wasIndex = playingAudio.value.index;
1281
- playingAudio.value = null;
1282
- if (wasIndex === index) return;
1283
- }
1284
- const src = beatAudios[index];
1285
- if (!src) return;
1286
- const audio = new Audio(src);
1287
- playingAudio.value = { index, audio };
1288
- audioProgress.value = 0;
1289
- audio.addEventListener("timeupdate", () => {
1290
- if (playingAudio.value?.index !== index) return;
1291
- if (audio.duration > 0) audioProgress.value = audio.currentTime / audio.duration;
1292
- });
1293
- audio.addEventListener("ended", () => {
1294
- if (playingAudio.value?.index !== index) return;
1295
- playingAudio.value = null;
1296
- audioProgress.value = 0;
1297
- if (lightbox.value?.index === index) advanceFromBeat(index);
1298
- });
1299
- audio.play();
1300
- }
1301
-
1302
- function onBeatDragOver(event: DragEvent, index: number) {
1303
- if (!event.dataTransfer?.types.includes("Files")) return;
1304
- event.preventDefault();
1305
- beatDragOver[index] = true;
1306
- }
1307
-
1308
- function onBeatDragLeave(index: number) {
1309
- beatDragOver[index] = false;
1310
- }
1311
-
1312
- async function onBeatDrop(event: DragEvent, index: number) {
1313
- event.preventDefault();
1314
- beatDragOver[index] = false;
1315
- const file = event.dataTransfer?.files[0];
1316
- if (!file || !file.type.startsWith("image/")) return;
1317
-
1318
- renderState[index] = "rendering";
1319
- Reflect.deleteProperty(renderErrors, index);
1320
- let imageData: string;
1321
- try {
1322
- imageData = await new Promise<string>((resolve, reject) => {
1323
- const reader = new FileReader();
1324
- reader.onload = () => resolve(reader.result as string);
1325
- reader.onerror = reject;
1326
- reader.readAsDataURL(file);
1327
- });
1328
- } catch (err) {
1329
- renderErrors[index] = errorMessage(err);
1330
- renderState[index] = "error";
1331
- return;
1332
- }
1333
- const response = await apiPost<{ image?: string; error?: string }>(endpoints.uploadBeatImage.url, {
1334
- filePath: filePath.value,
1335
- beatIndex: index,
1336
- imageData,
1337
- });
1338
- if (!response.ok) {
1339
- renderErrors[index] = response.error || "Upload failed";
1340
- renderState[index] = "error";
1341
- return;
1342
- }
1343
- if (response.data.error) {
1344
- renderErrors[index] = response.data.error;
1345
- renderState[index] = "error";
1346
- return;
1347
- }
1348
- renderedImages[index] = response.data.image ?? "";
1349
- renderState[index] = "done";
1350
- }
1351
-
1352
- function onCharDragOver(event: DragEvent, key: string) {
1353
- if (!event.dataTransfer?.types.includes("Files")) return;
1354
- event.preventDefault();
1355
- charDragOver[key] = true;
1356
- }
1357
-
1358
- function onCharDragLeave(key: string) {
1359
- charDragOver[key] = false;
1360
- }
1361
-
1362
- async function onCharDrop(event: DragEvent, key: string) {
1363
- event.preventDefault();
1364
- charDragOver[key] = false;
1365
- const file = event.dataTransfer?.files[0];
1366
- if (!file || !file.type.startsWith("image/")) return;
1367
-
1368
- charRenderState[key] = "rendering";
1369
- Reflect.deleteProperty(charErrors, key);
1370
- let imageData: string;
1371
- try {
1372
- imageData = await new Promise<string>((resolve, reject) => {
1373
- const reader = new FileReader();
1374
- reader.onload = () => resolve(reader.result as string);
1375
- reader.onerror = reject;
1376
- reader.readAsDataURL(file);
1377
- });
1378
- } catch (err) {
1379
- charErrors[key] = errorMessage(err);
1380
- charRenderState[key] = "error";
1381
- return;
1382
- }
1383
- const response = await apiPost<{ image?: string; error?: string }>(endpoints.uploadCharacterImage.url, { filePath: filePath.value, key, imageData });
1384
- if (!response.ok) {
1385
- charErrors[key] = response.error || "Upload failed";
1386
- charRenderState[key] = "error";
1387
- return;
1388
- }
1389
- if (response.data.error) {
1390
- charErrors[key] = response.data.error;
1391
- charRenderState[key] = "error";
1392
- return;
1393
- }
1394
- charImages[key] = response.data.image ?? "";
1395
- charRenderState[key] = "done";
1396
- }
1397
-
1398
- function openCharacterLightbox(key: string) {
1399
- // Stop both audio and silent timer — character lightbox is
1400
- // outside the play loop (#1073).
1401
- stopAllPlayback();
1402
- lightbox.value = {
1403
- src: charImages[key],
1404
- text: key,
1405
- index: -1,
1406
- isCharacter: true,
1407
- };
1408
- }
1409
-
1410
- async function loadExistingCharacterImage(key: string) {
1411
- const response = await apiGet<{ image?: string }>(endpoints.characterImage.url, { filePath: filePath.value, key });
1412
- // silently ignore errors
1413
- if (response.ok && response.data.image) {
1414
- charImages[key] = response.data.image;
1415
- charRenderState[key] = "done";
1416
- }
1417
- }
1418
-
1419
- function refreshMissingCharacterImages() {
1420
- getMissingCharacterKeys(characterKeys.value, charImages, charRenderState).forEach((key) => loadExistingCharacterImage(key));
1421
- }
1422
-
1423
- async function renderCharacter(key: string, force: boolean) {
1424
- charRenderState[key] = "rendering";
1425
- Reflect.deleteProperty(charErrors, key);
1426
- const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderCharacter.url, {
1427
- filePath: filePath.value,
1428
- key,
1429
- force,
1430
- chatSessionId: chatSessionId.value,
1431
- });
1432
- if (!response.ok) {
1433
- charErrors[key] = response.error || "Render failed";
1434
- charRenderState[key] = "error";
1435
- return;
1436
- }
1437
- if (response.data.error) {
1438
- charErrors[key] = response.data.error;
1439
- charRenderState[key] = "error";
1440
- return;
1441
- }
1442
- charImages[key] = response.data.image ?? "";
1443
- charRenderState[key] = "done";
1444
- }
1445
-
1446
- async function generateAllCharacters() {
1447
- await Promise.all(characterKeys.value.filter((key) => charRenderState[key] !== "rendering").map((key) => renderCharacter(key, false)));
1448
- }
1449
-
1450
- // Probe the server for an existing beat PNG before triggering any
1451
- // generation. Only auto-renders when the disk is empty AND the beat
1452
- // is a deterministic type — imagePrompt beats are left empty so the
1453
- // user clicks Generate explicitly (avoids surprise paid text2image
1454
- // calls on every page refresh).
1455
- async function hydrateBeatImage(beat: Beat, index: number, hasCharacters: boolean, autoRenderTypes: readonly string[]): Promise<void> {
1456
- await loadExistingBeatImage(index);
1457
- if (renderedImages[index]) return;
1458
- if (shouldAutoRenderBeat(beat, hasCharacters, autoRenderTypes)) {
1459
- await renderBeat(index);
1460
- }
1461
- }
1462
-
1463
- /**
1464
- * #1074 — keep the in-memory toolResult in sync with the on-disk
1465
- * script file. `update-beat` / `update-script` persist edits to
1466
- * disk, but the JSONL session entry that backs
1467
- * `props.selectedResult.data.script` is never rewritten, so a
1468
- * page reload + session-restore would otherwise surface stale
1469
- * pre-edit content.
1470
- *
1471
- * Why the reopen endpoint, not `/api/files/content`: `filePath`
1472
- * is the wire form `stories/<rel>` which `mulmoScript.save` knows
1473
- * how to translate back to the real on-disk path under
1474
- * `artifacts/stories/...` via `resolveStoryPath`. The generic
1475
- * file-content endpoint resolves against workspace root, so it
1476
- * 404s for the same wire form (and was silently masking #1074 in
1477
- * an earlier draft of this fix — see `[files] GET content: gated
1478
- * by resolve/stat` warnings in the server log). The reopen route
1479
- * is read-only when `script` is omitted; it does NOT trigger movie
1480
- * generation unless `autoGenerateMovie: true` is passed.
1481
- *
1482
- * The flow silently bails on every failure mode so a missing /
1483
- * malformed / deleted script file never blocks the rest of
1484
- * `initializeScript`.
1485
- *
1486
- * Stale-response guard: capture `uuid` + `filePath` before the
1487
- * `await`. If either has changed by the time the response lands
1488
- * (the user navigated to a different result while the request
1489
- * was in flight, or `props.selectedResult` was swapped under us
1490
- * by a parent watcher), drop the response on the floor — the new
1491
- * `initializeScript` invocation triggered by that change will
1492
- * issue its own refresh against the correct file.
1493
- */
1494
- async function refreshScriptFromDisk(): Promise<void> {
1495
- const requestedFilePath = filePath.value;
1496
- if (!requestedFilePath) return;
1497
- const requestedUuid = props.selectedResult.uuid;
1498
- const response = await apiPost<{ data?: { script?: MulmoScript } }>(endpoints.save.url, { filePath: requestedFilePath });
1499
- if (props.selectedResult.uuid !== requestedUuid || filePath.value !== requestedFilePath) return;
1500
- if (!response.ok) return;
1501
- const diskScript = response.data?.data?.script;
1502
- // Server-side `loadScriptFromDisk` already validated against
1503
- // `mulmoScriptSchema`, so a non-null `script` is trusted here —
1504
- // we only need a presence check.
1505
- if (!diskScript) return;
1506
- if (isSameScript(diskScript, script.value)) return;
1507
- emit("updateResult", {
1508
- ...props.selectedResult,
1509
- data: { ...props.selectedResult.data, script: diskScript },
1510
- });
1511
- }
1512
-
1513
- async function initializeScript() {
1514
- // Stop any in-flight playback BEFORE we tear down per-script state
1515
- // — a pending `silentPlaybackTimer` or running audio from the
1516
- // previous script would otherwise fire `advanceFromBeat()` against
1517
- // the new script's lightbox / beat list and either crash or
1518
- // silently jump the new presentation forward. Also close any open
1519
- // lightbox so the user lands on the clean View for the new result
1520
- // (Codex review iter-4 on #1365).
1521
- stopAllPlayback();
1522
- lightbox.value = null;
1523
- // Reset scroll position so new results start at the top
1524
- if (beatListEl.value) beatListEl.value.scrollTop = 0;
1525
- // Reset per-script state
1526
- Object.keys(renderState).forEach((key) => Reflect.deleteProperty(renderState, key));
1527
- Object.keys(renderedImages).forEach((key) => Reflect.deleteProperty(renderedImages, key));
1528
- Object.keys(renderErrors).forEach((key) => Reflect.deleteProperty(renderErrors, key));
1529
- Object.keys(sourceOpen).forEach((key) => Reflect.deleteProperty(sourceOpen, key));
1530
- Object.keys(sourceText).forEach((key) => Reflect.deleteProperty(sourceText, key));
1531
- Object.keys(beatSaveErrors).forEach((key) => Reflect.deleteProperty(beatSaveErrors, key));
1532
- Object.keys(beatSaving).forEach((key) => Reflect.deleteProperty(beatSaving, key));
1533
- Object.keys(localOverrides).forEach((key) => Reflect.deleteProperty(localOverrides, key));
1534
- Object.keys(beatAudios).forEach((key) => Reflect.deleteProperty(beatAudios, key));
1535
- Object.keys(audioState).forEach((key) => Reflect.deleteProperty(audioState, key));
1536
- Object.keys(audioErrors).forEach((key) => Reflect.deleteProperty(audioErrors, key));
1537
- Object.keys(charRenderState).forEach((key) => Reflect.deleteProperty(charRenderState, key));
1538
- Object.keys(charImages).forEach((key) => Reflect.deleteProperty(charImages, key));
1539
- Object.keys(charErrors).forEach((key) => Reflect.deleteProperty(charErrors, key));
1540
- Object.keys(beatDragOver).forEach((key) => Reflect.deleteProperty(beatDragOver, key));
1541
- resetBeatMovies();
1542
- moviePath.value = null;
1543
- pdfPath.value = null;
1544
- if (sourceDetails.value) sourceDetails.value.open = false;
1545
-
1546
- // #1074 — re-read the script file from disk before per-beat
1547
- // hydration. The server's `enrichWithMulmoScript`
1548
- // (server/api/routes/sessions.ts) already re-merges disk content
1549
- // into toolResult.data.script when the SPA reloads via
1550
- // `/api/sessions/:id`. But that path only fires on full page
1551
- // reload — when the user switches between tool results inside
1552
- // the same SPA mount and switches back, the in-memory ActiveSession
1553
- // toolResult still carries whatever script was captured when the
1554
- // SPA first booted, and `localOverrides` (the only thing showing
1555
- // the user's edit since the last save) is reset by initializeScript
1556
- // on remount. Re-fetching from disk via the reopen endpoint here
1557
- // covers that gap. See issue #1074 for the original repro.
1558
- await refreshScriptFromDisk();
1559
-
1560
- // Mount-time policy: prefer the existing PNG on the server. Every
1561
- // beat — deterministic AND imagePrompt — first probes /beat-image,
1562
- // and we only fall through to renderBeat() when the disk has nothing
1563
- // yet AND the type is safe to auto-render (deterministic content,
1564
- // no characters waiting). Without this probe a refresh would re-fire
1565
- // generateBeatImage for every beat, and for imagePrompt beats that
1566
- // means a paid text2image call against an image we already have.
1567
- //
1568
- // Stale-after-edit: if the user edits the script source the on-disk
1569
- // PNG is no longer in sync with the new content, but we don't try to
1570
- // detect that here — the per-beat ↺ button is one click away and a
1571
- // page refresh re-runs this same probe, so the user can opt back into
1572
- // a fresh render whenever they need to.
1573
- const AUTO_RENDER_TYPES = ["textSlide", "markdown", "chart", "mermaid", "html_tailwind", "slide"] as const;
1574
- const hasCharacters = characterKeys.value.length > 0;
1575
- beats.value.forEach((beat, index) => {
1576
- void hydrateBeatImage(beat, index, hasCharacters, AUTO_RENDER_TYPES);
1577
- if (beat.text) loadExistingBeatAudio(index);
1578
- if (beatMayHaveMovie(beat)) void loadExistingBeatMovie(index);
1579
- });
1580
-
1581
- characterKeys.value.forEach((key) => loadExistingCharacterImage(key));
1582
-
1583
- if (filePath.value) {
1584
- const response = await apiGet<{ moviePath?: string }>(endpoints.movieStatus.url, { filePath: filePath.value });
1585
- if (response.ok && response.data.moviePath) {
1586
- moviePath.value = response.data.moviePath;
1587
- }
1588
- // ignore errors
1589
- // Also check whether a PDF was previously generated and is still
1590
- // newer than the source; status returns null otherwise so the UI
1591
- // re-offers the Generate button.
1592
- const pdfResponse = await apiGet<{ pdfPath?: string }>(endpoints.pdfStatus.url, { filePath: filePath.value });
1593
- if (pdfResponse.ok && pdfResponse.data.pdfPath) {
1594
- pdfPath.value = pdfResponse.data.pdfPath;
1595
- }
1596
- }
1597
-
1598
- // Reflect any generations that were already in flight when we
1599
- // mounted (user switched away mid-generation and came back).
1600
- for (const entry of Object.values(pendingForThisScript.value)) {
1601
- reflectGenerationStart(entry);
1602
- }
1603
- }
1604
-
1605
- onMounted(initializeScript);
1606
- watch(() => props.selectedResult, initializeScript);
1607
-
1608
- // Keep the view in sync with generations that started from a different
1609
- // view mount or a parallel tab. When a generation for this script
1610
- // disappears from session.pendingGenerations we reload the relevant
1611
- // artifact off disk; when one appears we mirror it into the local
1612
- // "rendering" state so spinners show even after a remount.
1613
- watch(pendingForThisScript, (now, prev = {}) => {
1614
- for (const [mapKey, entry] of Object.entries(now)) {
1615
- if (!(mapKey in prev)) reflectGenerationStart(entry);
1616
- }
1617
- for (const [mapKey, entry] of Object.entries(prev)) {
1618
- if (!(mapKey in now)) {
1619
- // Fire-and-forget: the watcher callback must stay sync so Vue
1620
- // can batch multiple pendingGenerations updates. Swallow + log
1621
- // so a failed reload doesn't surface as an unhandled rejection.
1622
- reflectGenerationFinish(entry).catch((err) => {
1623
- console.error("[presentMulmoScript] reload on finish failed:", err);
1624
- });
1625
- }
1626
- }
1627
- });
1628
-
1629
- function reflectGenerationStart(entry: PendingGeneration): void {
1630
- if (entry.kind === GENERATION_KINDS.beatImage) {
1631
- const idx = Number(entry.key);
1632
- if (!renderedImages[idx]) renderState[idx] = "rendering";
1633
- } else if (entry.kind === GENERATION_KINDS.beatAudio) {
1634
- const idx = Number(entry.key);
1635
- if (!beatAudios[idx]) audioState[idx] = "generating";
1636
- } else if (entry.kind === GENERATION_KINDS.characterImage) {
1637
- if (!charImages[entry.key]) charRenderState[entry.key] = "rendering";
1638
- } else if (entry.kind === GENERATION_KINDS.movie) {
1639
- movieGenerating.value = true;
1640
- }
1641
- }
1642
-
1643
- async function reflectGenerationFinish(entry: PendingGeneration): Promise<void> {
1644
- if (entry.kind === GENERATION_KINDS.beatImage) {
1645
- const idx = Number(entry.key);
1646
- await loadExistingBeatImage(idx);
1647
- if (beatMayHaveMovie(effectiveBeat(idx))) await loadExistingBeatMovie(idx);
1648
- if (renderState[idx] === "rendering") Reflect.deleteProperty(renderState, idx);
1649
- } else if (entry.kind === GENERATION_KINDS.beatAudio) {
1650
- const idx = Number(entry.key);
1651
- await loadExistingBeatAudio(idx);
1652
- if (audioState[idx] === "generating") Reflect.deleteProperty(audioState, idx);
1653
- } else if (entry.kind === GENERATION_KINDS.characterImage) {
1654
- await loadExistingCharacterImage(entry.key);
1655
- if (charRenderState[entry.key] === "rendering") {
1656
- Reflect.deleteProperty(charRenderState, entry.key);
1657
- }
1658
- } else if (entry.kind === GENERATION_KINDS.movie) {
1659
- movieGenerating.value = false;
1660
- await refreshMoviePath();
1661
- } else if (entry.kind === GENERATION_KINDS.pdf) {
1662
- pdfGenerating.value = false;
1663
- await refreshPdfPath();
1664
- }
1665
- }
1666
-
1667
- async function refreshMoviePath(): Promise<void> {
1668
- if (!filePath.value) return;
1669
- const response = await apiGet<{ moviePath?: string }>(endpoints.movieStatus.url, { filePath: filePath.value });
1670
- if (response.ok && response.data.moviePath) {
1671
- moviePath.value = response.data.moviePath;
1672
- }
1673
- }
1674
-
1675
- async function generateMovie() {
1676
- movieGenerating.value = true;
1677
- movieError.value = null;
1678
- try {
1679
- const res = await apiFetchRaw(endpoints.generateMovie.url, {
1680
- method: "POST",
1681
- body: JSON.stringify({
1682
- filePath: filePath.value,
1683
- chatSessionId: chatSessionId.value,
1684
- }),
1685
- headers: { "Content-Type": "application/json" },
1686
- });
1687
- if (!res.ok || !res.body) throw new Error("Generation failed");
1688
- await streamMovieEvents(res.body, {
1689
- onBeatImageDone: (beatIndex) => {
1690
- loadExistingBeatImage(beatIndex);
1691
- refreshMissingCharacterImages();
1692
- if (beatMayHaveMovie(effectiveBeat(beatIndex))) void loadExistingBeatMovie(beatIndex);
1693
- },
1694
- onBeatAudioDone: (beatIndex) => loadExistingBeatAudio(beatIndex),
1695
- onDone: (path) => {
1696
- moviePath.value = path;
1697
- },
1698
- });
1699
- } catch (err) {
1700
- // Surface inline (instead of `alert()` which blocks + has no
1701
- // retry affordance). The error chip with a retry button lives
1702
- // next to the generate button in the template (#1197).
1703
- movieError.value = extractErrorMessage(err);
1704
- } finally {
1705
- movieGenerating.value = false;
1706
- }
1707
- }
1708
-
1709
- // Bearer-authenticated movie download. apiFetchRaw auto-attaches the
1710
- // Authorization header (which a plain `<a href download>` cannot), so
1711
- // the route stays behind the standard /api/* bearer guard. The blob
1712
- // is hooked to a synthetic anchor whose `download` attribute carries
1713
- // the filename — the browser still surfaces a native save dialog.
1714
- async function downloadMovie() {
1715
- if (!moviePath.value || movieDownloading.value) return;
1716
- movieDownloading.value = true;
1717
- let objectUrl: string | null = null;
1718
- try {
1719
- const res = await apiFetchRaw(endpoints.downloadMovie.url, {
1720
- method: "GET",
1721
- query: { moviePath: moviePath.value },
1722
- });
1723
- if (!res.ok) {
1724
- throw new Error(`HTTP ${res.status}`);
1725
- }
1726
- const blob = await res.blob();
1727
- objectUrl = URL.createObjectURL(blob);
1728
- const filename = moviePath.value.split("/").pop() ?? "movie.mp4";
1729
- const anchor = document.createElement("a");
1730
- anchor.href = objectUrl;
1731
- anchor.download = filename;
1732
- document.body.appendChild(anchor);
1733
- anchor.click();
1734
- anchor.remove();
1735
- } catch (err) {
1736
- alert(extractErrorMessage(err));
1737
- } finally {
1738
- if (objectUrl) URL.revokeObjectURL(objectUrl);
1739
- movieDownloading.value = false;
1740
- }
1741
- }
1742
-
1743
- // --- PDF (#1614) ---------------------------------------------------
1744
- //
1745
- // Same triple as movie: status poll → SSE generate → bearer-auth
1746
- // download. Reuses `streamMovieEvents`'s shape — the server emits
1747
- // `beat_image_done` (per-beat image progress) and `done` (final
1748
- // PDF), with `done` carrying `pdfPath` instead of `moviePath`.
1749
-
1750
- async function refreshPdfPath(): Promise<void> {
1751
- if (!filePath.value) return;
1752
- const response = await apiGet<{ pdfPath?: string }>(endpoints.pdfStatus.url, { filePath: filePath.value });
1753
- if (response.ok && response.data.pdfPath) {
1754
- pdfPath.value = response.data.pdfPath;
1755
- }
1756
- }
1757
-
1758
- async function generatePdf() {
1759
- pdfGenerating.value = true;
1760
- try {
1761
- const res = await apiFetchRaw(endpoints.generatePdf.url, {
1762
- method: "POST",
1763
- body: JSON.stringify({
1764
- filePath: filePath.value,
1765
- chatSessionId: chatSessionId.value,
1766
- }),
1767
- headers: { "Content-Type": "application/json" },
1768
- });
1769
- if (!res.ok || !res.body) throw new Error("PDF generation failed");
1770
- // Re-use `streamMovieEvents`: the server emits the same per-beat
1771
- // image progress + a terminal `done` event whose payload here is
1772
- // `{ pdfPath }` (not `moviePath`). `onDone`'s argument is the
1773
- // raw string parser surfaced via `moviePath`, so we just rebind
1774
- // it locally.
1775
- await streamMovieEvents(res.body, {
1776
- onBeatImageDone: (beatIndex) => {
1777
- loadExistingBeatImage(beatIndex);
1778
- refreshMissingCharacterImages();
1779
- },
1780
- onBeatAudioDone: () => {
1781
- // PDF flow doesn't run audio; ignore.
1782
- },
1783
- onDone: (path) => {
1784
- pdfPath.value = path;
1785
- },
1786
- });
1787
- } catch (err) {
1788
- alert(extractErrorMessage(err));
1789
- } finally {
1790
- pdfGenerating.value = false;
1791
- }
1792
- }
1793
-
1794
- async function downloadPdf() {
1795
- if (!pdfPath.value || pdfDownloading.value) return;
1796
- pdfDownloading.value = true;
1797
- let objectUrl: string | null = null;
1798
- try {
1799
- const res = await apiFetchRaw(endpoints.downloadPdf.url, {
1800
- method: "GET",
1801
- query: { pdfPath: pdfPath.value },
1802
- });
1803
- if (!res.ok) {
1804
- throw new Error(`HTTP ${res.status}`);
1805
- }
1806
- const blob = await res.blob();
1807
- objectUrl = URL.createObjectURL(blob);
1808
- const filename = pdfPath.value.split("/").pop() ?? "deck.pdf";
1809
- const anchor = document.createElement("a");
1810
- anchor.href = objectUrl;
1811
- anchor.download = filename;
1812
- document.body.appendChild(anchor);
1813
- anchor.click();
1814
- anchor.remove();
1815
- } catch (err) {
1816
- alert(extractErrorMessage(err));
1817
- } finally {
1818
- if (objectUrl) URL.revokeObjectURL(objectUrl);
1819
- pdfDownloading.value = false;
1820
- }
1821
- }
1822
- </script>
1823
-
1824
- <style scoped>
1825
- .bottom-bar-wrapper {
1826
- position: relative;
1827
- flex-shrink: 0;
1828
- }
1829
-
1830
- .script-source {
1831
- padding: 0.5rem;
1832
- background: #f5f5f5;
1833
- border-top: 1px solid #e0e0e0;
1834
- font-family: Consolas, "MS Gothic", "BIZ UDGothic", monospace;
1835
- font-size: 0.85rem;
1836
- }
1837
-
1838
- .script-source summary {
1839
- cursor: pointer;
1840
- user-select: none;
1841
- padding: 0.5rem;
1842
- background: #e8e8e8;
1843
- border-radius: 4px;
1844
- font-weight: 500;
1845
- color: #333;
1846
- }
1847
-
1848
- .script-source[open] summary {
1849
- margin-bottom: 0.5rem;
1850
- }
1851
-
1852
- .script-source summary:hover {
1853
- background: #d8d8d8;
1854
- }
1855
-
1856
- .script-editor {
1857
- width: 100%;
1858
- height: 40vh;
1859
- padding: 1rem;
1860
- background: #ffffff;
1861
- border: 1px solid #ccc;
1862
- border-radius: 4px;
1863
- color: #333;
1864
- font-family: "Courier New", "MS Gothic", "BIZ UDGothic", monospace;
1865
- font-size: 0.9rem;
1866
- resize: vertical;
1867
- margin-bottom: 0.5rem;
1868
- line-height: 1.5;
1869
- }
1870
-
1871
- .script-editor:focus {
1872
- outline: none;
1873
- border-color: #4caf50;
1874
- box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
1875
- }
1876
-
1877
- .script-editor-invalid {
1878
- border-color: #ef4444;
1879
- }
1880
-
1881
- .script-editor-invalid:focus {
1882
- border-color: #ef4444;
1883
- box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
1884
- }
1885
-
1886
- .editor-actions {
1887
- display: flex;
1888
- justify-content: space-between;
1889
- }
1890
-
1891
- .apply-btn {
1892
- padding: 0.5rem 1rem;
1893
- background: #4caf50;
1894
- color: white;
1895
- border: none;
1896
- border-radius: 4px;
1897
- cursor: pointer;
1898
- font-size: 0.9rem;
1899
- transition: background 0.2s;
1900
- font-weight: 500;
1901
- }
1902
-
1903
- .apply-btn:hover {
1904
- background: #45a049;
1905
- }
1906
-
1907
- .apply-btn:disabled {
1908
- background: #cccccc;
1909
- color: #666666;
1910
- cursor: not-allowed;
1911
- opacity: 0.6;
1912
- }
1913
-
1914
- .cancel-btn {
1915
- padding: 0.5rem 1rem;
1916
- background: #e0e0e0;
1917
- color: #333;
1918
- border: none;
1919
- border-radius: 4px;
1920
- cursor: pointer;
1921
- font-size: 0.9rem;
1922
- transition: background 0.2s;
1923
- font-weight: 500;
1924
- }
1925
-
1926
- .cancel-btn:hover {
1927
- background: #d0d0d0;
1928
- }
1929
-
1930
- .copy-btn {
1931
- position: absolute;
1932
- bottom: 0.3rem;
1933
- right: 0.65rem;
1934
- padding: 0.4rem;
1935
- background: none;
1936
- border: none;
1937
- color: #333;
1938
- cursor: pointer;
1939
- z-index: 1;
1940
- }
1941
-
1942
- .copy-btn:hover {
1943
- color: #000;
1944
- }
1945
-
1946
- .copy-btn .material-icons {
1947
- font-size: 1.15rem;
1948
- }
1949
- </style>