mulmoclaude 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,1166 @@
|
|
|
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">
|
|
7
|
+
{{ script.title || "Untitled Script" }}
|
|
8
|
+
</h2>
|
|
9
|
+
<p v-if="script.description" class="text-sm text-gray-500 mt-0.5 truncate">
|
|
10
|
+
{{ script.description }}
|
|
11
|
+
</p>
|
|
12
|
+
<div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
|
13
|
+
<span>{{ beats.length }} beat{{ beats.length !== 1 ? "s" : "" }}</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 gap-2">
|
|
19
|
+
<!-- Download Movie -->
|
|
20
|
+
<a
|
|
21
|
+
v-if="moviePath && !movieGenerating"
|
|
22
|
+
:href="`${downloadMovieBase}?moviePath=${encodeURIComponent(moviePath)}`"
|
|
23
|
+
download
|
|
24
|
+
class="px-3 py-1 text-xs rounded-full border transition-colors border-gray-200 text-gray-500 hover:bg-gray-50 flex items-center justify-center gap-1"
|
|
25
|
+
>
|
|
26
|
+
<span class="material-icons text-sm leading-none">download</span>
|
|
27
|
+
<span>Movie</span>
|
|
28
|
+
</a>
|
|
29
|
+
<!-- Generate / Regenerate Movie -->
|
|
30
|
+
<button
|
|
31
|
+
class="px-3 py-1 text-xs rounded-full border transition-colors border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 flex items-center justify-center gap-1"
|
|
32
|
+
:disabled="movieGenerating"
|
|
33
|
+
@click="generateMovie"
|
|
34
|
+
>
|
|
35
|
+
<svg v-if="movieGenerating" class="animate-spin w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none">
|
|
36
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
37
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
38
|
+
</svg>
|
|
39
|
+
<span v-if="movieGenerating">Generating…</span>
|
|
40
|
+
<template v-else>
|
|
41
|
+
<span class="material-icons text-sm leading-none">refresh</span>
|
|
42
|
+
<span>Movie</span>
|
|
43
|
+
</template>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Characters section -->
|
|
49
|
+
<div v-if="characterKeys.length > 0" class="border-b border-gray-100 shrink-0 px-4 py-3">
|
|
50
|
+
<div class="flex items-center justify-between mb-2">
|
|
51
|
+
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Characters</span>
|
|
52
|
+
<button
|
|
53
|
+
class="px-2 py-0.5 text-xs rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
54
|
+
:disabled="movieGenerating || anyBeatRendering || characterKeys.every((key) => charRenderState[key] === 'rendering')"
|
|
55
|
+
@click="generateAllCharacters"
|
|
56
|
+
>
|
|
57
|
+
Generate All
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="flex gap-3 flex-wrap">
|
|
61
|
+
<div v-for="key in characterKeys" :key="key" class="flex flex-col items-center gap-1 w-36">
|
|
62
|
+
<!-- Character thumbnail -->
|
|
63
|
+
<div
|
|
64
|
+
class="relative w-36 h-36 rounded-lg border overflow-hidden bg-gray-50 flex items-center justify-center transition-colors"
|
|
65
|
+
:class="charDragOver[key] ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
|
|
66
|
+
@dragover="onCharDragOver($event, key)"
|
|
67
|
+
@dragleave="onCharDragLeave(key)"
|
|
68
|
+
@drop="onCharDrop($event, key)"
|
|
69
|
+
>
|
|
70
|
+
<img
|
|
71
|
+
v-if="charImages[key]"
|
|
72
|
+
:src="charImages[key]"
|
|
73
|
+
class="w-full h-full object-cover cursor-zoom-in"
|
|
74
|
+
:alt="key"
|
|
75
|
+
@click="openCharacterLightbox(key)"
|
|
76
|
+
/>
|
|
77
|
+
<template v-else-if="charRenderState[key] === 'rendering'">
|
|
78
|
+
<svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
|
|
79
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
80
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
81
|
+
</svg>
|
|
82
|
+
</template>
|
|
83
|
+
<template v-else-if="charRenderState[key] === 'error'">
|
|
84
|
+
<span class="text-xs text-red-400 text-center px-1">{{ charErrors[key] }}</span>
|
|
85
|
+
</template>
|
|
86
|
+
<template v-else>
|
|
87
|
+
<span class="text-xs text-gray-300 text-center px-1 leading-tight">{{ characterPrompt(key) }}</span>
|
|
88
|
+
</template>
|
|
89
|
+
<!-- Permanent drop hint -->
|
|
90
|
+
<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">
|
|
91
|
+
or drop image
|
|
92
|
+
</div>
|
|
93
|
+
<!-- Drop overlay -->
|
|
94
|
+
<div v-if="charDragOver[key]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
|
|
95
|
+
<span class="text-xs text-blue-500 font-medium">Drop</span>
|
|
96
|
+
</div>
|
|
97
|
+
<!-- Regenerate button -->
|
|
98
|
+
<button
|
|
99
|
+
v-if="charImages[key] && charRenderState[key] !== 'rendering'"
|
|
100
|
+
class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
|
|
101
|
+
:class="
|
|
102
|
+
movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
|
103
|
+
"
|
|
104
|
+
:disabled="movieGenerating || anyBeatRendering"
|
|
105
|
+
@click.stop="renderCharacter(key, true)"
|
|
106
|
+
>
|
|
107
|
+
<span v-if="movieGenerating || anyBeatRendering" class="inline-block animate-spin">↺</span>
|
|
108
|
+
<span v-else>↺</span>
|
|
109
|
+
</button>
|
|
110
|
+
<!-- Generate button -->
|
|
111
|
+
<button
|
|
112
|
+
v-else-if="!charImages[key] && charRenderState[key] !== 'rendering'"
|
|
113
|
+
class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
|
|
114
|
+
:class="
|
|
115
|
+
movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-blue-400 text-blue-600 hover:bg-blue-50'
|
|
116
|
+
"
|
|
117
|
+
:disabled="movieGenerating || anyBeatRendering"
|
|
118
|
+
@click.stop="renderCharacter(key, false)"
|
|
119
|
+
>
|
|
120
|
+
<svg v-if="movieGenerating || anyBeatRendering" class="animate-spin w-3 h-3" 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-else>Gen</span>
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
<span class="text-xs text-gray-600 text-center truncate w-full">{{ key }}</span>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Beat list -->
|
|
133
|
+
<div ref="beatListEl" class="flex-1 overflow-y-auto p-2 space-y-1.5">
|
|
134
|
+
<div v-for="(beat, index) in beats" :key="index" class="rounded-lg border border-gray-200 overflow-hidden">
|
|
135
|
+
<!-- Beat body: thumbnail + narration side by side -->
|
|
136
|
+
<div class="flex gap-3 items-stretch">
|
|
137
|
+
<!-- Thumbnail -->
|
|
138
|
+
<div
|
|
139
|
+
class="relative shrink-0 w-[45%] overflow-hidden bg-gray-50 transition-colors"
|
|
140
|
+
:class="beatDragOver[index] ? 'bg-blue-50' : ''"
|
|
141
|
+
@dragover="onBeatDragOver($event, index)"
|
|
142
|
+
@dragleave="onBeatDragLeave(index)"
|
|
143
|
+
@drop="onBeatDrop($event, index)"
|
|
144
|
+
>
|
|
145
|
+
<img
|
|
146
|
+
v-if="renderedImages[index]"
|
|
147
|
+
:src="renderedImages[index]"
|
|
148
|
+
class="w-full object-contain cursor-zoom-in"
|
|
149
|
+
:alt="`Beat ${index + 1}`"
|
|
150
|
+
@click="openLightbox(index)"
|
|
151
|
+
/>
|
|
152
|
+
<button
|
|
153
|
+
v-if="renderedImages[index] && renderState[index] !== 'rendering'"
|
|
154
|
+
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"
|
|
155
|
+
:disabled="movieGenerating"
|
|
156
|
+
@click.stop="regenerateBeat(index)"
|
|
157
|
+
>
|
|
158
|
+
↺
|
|
159
|
+
</button>
|
|
160
|
+
<div v-else-if="!renderedImages[index]" class="w-full aspect-video flex flex-col items-center justify-center gap-1 p-2">
|
|
161
|
+
<template v-if="renderState[index] === 'rendering' || (movieGenerating && !renderedImages[index] && effectiveBeat(index).imagePrompt)">
|
|
162
|
+
<svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
|
|
163
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
164
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
165
|
+
</svg>
|
|
166
|
+
<span class="text-xs text-green-500">Rendering…</span>
|
|
167
|
+
</template>
|
|
168
|
+
<template v-else-if="renderState[index] === 'error'">
|
|
169
|
+
<span class="text-xs text-red-400 text-center">{{ renderErrors[index] }}</span>
|
|
170
|
+
</template>
|
|
171
|
+
<template v-else>
|
|
172
|
+
<span v-if="effectiveBeat(index).imagePrompt" class="text-xs text-gray-400 text-center italic leading-relaxed px-1">{{
|
|
173
|
+
effectiveBeat(index).imagePrompt
|
|
174
|
+
}}</span>
|
|
175
|
+
<span v-else class="text-xs text-gray-300">{{ beat.image?.type ?? "—" }}</span>
|
|
176
|
+
</template>
|
|
177
|
+
</div>
|
|
178
|
+
<!-- Beat drop hint / overlay -->
|
|
179
|
+
<div v-if="beatDragOver[index]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
|
|
180
|
+
<span class="text-xs text-blue-500 font-medium">Drop</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div
|
|
183
|
+
v-else-if="!renderedImages[index] && renderState[index] !== 'rendering'"
|
|
184
|
+
class="absolute bottom-0 inset-x-0 text-center text-xs text-gray-400 bg-white/70 py-0.5 pointer-events-none"
|
|
185
|
+
>
|
|
186
|
+
or drop image
|
|
187
|
+
</div>
|
|
188
|
+
<!-- Generate button for imagePrompt beats -->
|
|
189
|
+
<button
|
|
190
|
+
v-if="effectiveBeat(index).imagePrompt && !renderedImages[index] && renderState[index] !== 'rendering' && !movieGenerating"
|
|
191
|
+
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"
|
|
192
|
+
@click="renderBeat(index)"
|
|
193
|
+
>
|
|
194
|
+
Generate
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- Narration text -->
|
|
199
|
+
<div class="flex flex-col flex-1 min-w-0 px-2 py-1.5">
|
|
200
|
+
<span class="text-sm text-gray-800 leading-relaxed">{{ effectiveBeat(index).text }}</span>
|
|
201
|
+
<div class="flex justify-between mt-auto pt-1">
|
|
202
|
+
<!-- Audio controls -->
|
|
203
|
+
<div class="flex items-center gap-1">
|
|
204
|
+
<template v-if="audioState[index] === 'generating' || (movieGenerating && !beatAudios[index] && effectiveBeat(index).text)">
|
|
205
|
+
<svg class="animate-spin w-3 h-3 text-green-400" viewBox="0 0 24 24" fill="none">
|
|
206
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
207
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
208
|
+
</svg>
|
|
209
|
+
</template>
|
|
210
|
+
<button
|
|
211
|
+
v-else-if="beatAudios[index]"
|
|
212
|
+
class="text-xs px-2 py-0.5 rounded border"
|
|
213
|
+
:class="playingAudio?.index === index ? 'border-red-400 text-red-600 hover:bg-red-50' : 'border-green-400 text-green-600 hover:bg-green-50'"
|
|
214
|
+
@click="playAudio(index)"
|
|
215
|
+
>
|
|
216
|
+
{{ playingAudio?.index === index ? "■ Stop" : "▶ Play" }}
|
|
217
|
+
</button>
|
|
218
|
+
<template v-else-if="audioErrors[index]">
|
|
219
|
+
<span class="text-xs text-red-400" :title="audioErrors[index]">⚠ Error</span>
|
|
220
|
+
<button
|
|
221
|
+
v-if="effectiveBeat(index).text"
|
|
222
|
+
class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
223
|
+
:disabled="movieGenerating"
|
|
224
|
+
@click="generateAudio(index)"
|
|
225
|
+
>
|
|
226
|
+
↺
|
|
227
|
+
</button>
|
|
228
|
+
</template>
|
|
229
|
+
<button
|
|
230
|
+
v-else-if="effectiveBeat(index).text"
|
|
231
|
+
class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50"
|
|
232
|
+
@click="generateAudio(index)"
|
|
233
|
+
>
|
|
234
|
+
♪ Generate
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
<button class="text-gray-400 hover:text-gray-600" :title="sourceOpen[index] ? 'Hide source' : 'Show source'" @click="toggleSource(index)">
|
|
238
|
+
<svg
|
|
239
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
240
|
+
class="w-3.5 h-3.5"
|
|
241
|
+
viewBox="0 0 24 24"
|
|
242
|
+
fill="none"
|
|
243
|
+
stroke="currentColor"
|
|
244
|
+
stroke-width="2"
|
|
245
|
+
stroke-linecap="round"
|
|
246
|
+
stroke-linejoin="round"
|
|
247
|
+
>
|
|
248
|
+
<polyline points="16 18 22 12 16 6" />
|
|
249
|
+
<polyline points="8 6 2 12 8 18" />
|
|
250
|
+
</svg>
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- Source editor -->
|
|
257
|
+
<div v-if="sourceOpen[index]" class="border-t border-gray-100">
|
|
258
|
+
<textarea
|
|
259
|
+
v-model="sourceText[index]"
|
|
260
|
+
class="w-full text-xs text-gray-600 bg-gray-50 p-2 font-mono resize-none"
|
|
261
|
+
:class="isValidBeat(index) ? 'outline-none' : 'outline outline-2 outline-red-400'"
|
|
262
|
+
rows="8"
|
|
263
|
+
spellcheck="false"
|
|
264
|
+
/>
|
|
265
|
+
<div class="flex items-center justify-end gap-2 px-2 pb-2">
|
|
266
|
+
<span v-if="beatSaveErrors[index]" class="text-xs text-red-600" role="alert">⚠ {{ beatSaveErrors[index] }}</span>
|
|
267
|
+
<button
|
|
268
|
+
class="px-2 py-1 text-xs rounded border"
|
|
269
|
+
:class="
|
|
270
|
+
isValidBeat(index) && !beatSaving[index]
|
|
271
|
+
? 'border-blue-400 text-blue-600 hover:bg-blue-50 cursor-pointer'
|
|
272
|
+
: 'border-gray-200 text-gray-300 cursor-not-allowed'
|
|
273
|
+
"
|
|
274
|
+
:disabled="!isValidBeat(index) || !!beatSaving[index]"
|
|
275
|
+
@click="updateBeat(index)"
|
|
276
|
+
>
|
|
277
|
+
{{ beatSaving[index] ? "Saving…" : "Update" }}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<div v-if="beats.length === 0" class="flex items-center justify-center h-32 text-gray-400 text-sm">No beats found in script</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- Bottom bar: Edit Script Source + Copy -->
|
|
287
|
+
<div class="bottom-bar-wrapper">
|
|
288
|
+
<details ref="sourceDetails" class="script-source" @toggle="onSourceToggle(($event.target as HTMLDetailsElement).open)">
|
|
289
|
+
<summary>Edit Script Source</summary>
|
|
290
|
+
<textarea
|
|
291
|
+
v-model="editableSource"
|
|
292
|
+
class="script-editor"
|
|
293
|
+
:class="{ 'script-editor-invalid': sourceChanged && !sourceValid }"
|
|
294
|
+
spellcheck="false"
|
|
295
|
+
></textarea>
|
|
296
|
+
<div class="editor-actions">
|
|
297
|
+
<button class="apply-btn" :disabled="!sourceChanged || !sourceValid" @click="applySource">Apply Changes</button>
|
|
298
|
+
<button class="cancel-btn" @click="cancelSourceEdit">Cancel</button>
|
|
299
|
+
</div>
|
|
300
|
+
</details>
|
|
301
|
+
<button v-show="!editing" class="copy-btn" :title="copied ? 'Copied!' : 'Copy'" @click="copyText">
|
|
302
|
+
<span class="material-icons">{{ copied ? "check" : "content_copy" }}</span>
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<!-- Lightbox -->
|
|
307
|
+
<div v-if="lightbox" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80" @click="lightbox = null">
|
|
308
|
+
<div class="flex items-center gap-4" @click.stop>
|
|
309
|
+
<button
|
|
310
|
+
v-if="!lightbox.isCharacter"
|
|
311
|
+
class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
|
|
312
|
+
:disabled="!hasPrev"
|
|
313
|
+
@click="lightboxMove(-1)"
|
|
314
|
+
>
|
|
315
|
+
‹
|
|
316
|
+
</button>
|
|
317
|
+
<div class="flex flex-col items-center gap-3">
|
|
318
|
+
<img :src="lightbox.src" class="max-w-[80vw] max-h-[80vh] object-contain rounded shadow-2xl" />
|
|
319
|
+
<div class="flex items-center gap-4">
|
|
320
|
+
<p v-if="lightbox.text" class="max-w-[80vw] text-center text-white text-2xl leading-relaxed">
|
|
321
|
+
{{ lightbox.text }}
|
|
322
|
+
</p>
|
|
323
|
+
<button
|
|
324
|
+
v-if="beatAudios[lightbox.index]"
|
|
325
|
+
class="shrink-0 text-sm px-3 py-1 rounded border"
|
|
326
|
+
:class="
|
|
327
|
+
playingAudio?.index === lightbox.index ? 'border-red-400 text-red-400 hover:bg-red-400/20' : 'border-white/60 text-white/60 hover:bg-white/20'
|
|
328
|
+
"
|
|
329
|
+
@click="playAudio(lightbox.index)"
|
|
330
|
+
>
|
|
331
|
+
{{ playingAudio?.index === lightbox.index ? "■ Stop" : "▶ Play" }}
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
<button
|
|
336
|
+
v-if="!lightbox.isCharacter"
|
|
337
|
+
class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
|
|
338
|
+
:disabled="!hasNext"
|
|
339
|
+
@click="lightboxMove(1)"
|
|
340
|
+
>
|
|
341
|
+
›
|
|
342
|
+
</button>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</template>
|
|
347
|
+
|
|
348
|
+
<script setup lang="ts">
|
|
349
|
+
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
350
|
+
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
351
|
+
import type { MulmoScriptData } from "./index";
|
|
352
|
+
import { mulmoBeatSchema, mulmoScriptSchema } from "@mulmocast/types";
|
|
353
|
+
import { extractErrorMessage, getMissingCharacterKeys, shouldAutoRenderBeat, streamMovieEvents, validateBeatJSON } from "./helpers";
|
|
354
|
+
import { apiGet, apiPost, apiFetchRaw } from "../../utils/api";
|
|
355
|
+
import { API_ROUTES } from "../../config/apiRoutes";
|
|
356
|
+
import { errorMessage } from "../../utils/errors";
|
|
357
|
+
import { useClipboardCopy } from "../../composables/useClipboardCopy";
|
|
358
|
+
import { useActiveSession } from "../../composables/useActiveSession";
|
|
359
|
+
import { GENERATION_KINDS, type PendingGeneration } from "../../types/events";
|
|
360
|
+
|
|
361
|
+
interface Beat {
|
|
362
|
+
speaker?: string;
|
|
363
|
+
text?: string;
|
|
364
|
+
id?: string;
|
|
365
|
+
imagePrompt?: string;
|
|
366
|
+
image?: { type: string; [key: string]: unknown };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface ImageEntry {
|
|
370
|
+
type: string;
|
|
371
|
+
prompt?: string;
|
|
372
|
+
[key: string]: unknown;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface MulmoScript {
|
|
376
|
+
title?: string;
|
|
377
|
+
description?: string;
|
|
378
|
+
lang?: string;
|
|
379
|
+
beats?: Beat[];
|
|
380
|
+
imageParams?: {
|
|
381
|
+
images?: Record<string, ImageEntry>;
|
|
382
|
+
[key: string]: unknown;
|
|
383
|
+
};
|
|
384
|
+
[key: string]: unknown;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const props = defineProps<{
|
|
388
|
+
selectedResult: ToolResultComplete<MulmoScriptData>;
|
|
389
|
+
}>();
|
|
390
|
+
const emit = defineEmits<{ updateResult: [result: ToolResultComplete] }>();
|
|
391
|
+
|
|
392
|
+
const data = computed(() => props.selectedResult.data);
|
|
393
|
+
const script = computed<MulmoScript>(() => data.value?.script ?? {});
|
|
394
|
+
const filePath = computed(() => data.value?.filePath ?? "");
|
|
395
|
+
const beats = computed<Beat[]>(() => script.value.beats ?? []);
|
|
396
|
+
|
|
397
|
+
// Exposed to the template so the `<a :href="...">` download button
|
|
398
|
+
// can compose a query-string URL without inlining the API path.
|
|
399
|
+
const downloadMovieBase = API_ROUTES.mulmoScript.downloadMovie;
|
|
400
|
+
|
|
401
|
+
// Per-beat render state
|
|
402
|
+
type RenderState = "idle" | "rendering" | "done" | "error";
|
|
403
|
+
const renderState = reactive<Record<number, RenderState>>({});
|
|
404
|
+
const renderedImages = reactive<Record<number, string>>({});
|
|
405
|
+
const renderErrors = reactive<Record<number, string>>({});
|
|
406
|
+
const sourceOpen = reactive<Record<number, boolean>>({});
|
|
407
|
+
const sourceText = reactive<Record<number, string>>({});
|
|
408
|
+
// Surface POST /api/mulmo-script/update-beat failures inline next to
|
|
409
|
+
// the Update button. Cleared on next successful save or editor close.
|
|
410
|
+
const beatSaveErrors = reactive<Record<number, string>>({});
|
|
411
|
+
const beatSaving = reactive<Record<number, boolean>>({});
|
|
412
|
+
const localOverrides = reactive<Record<number, Beat>>({});
|
|
413
|
+
const movieGenerating = ref(false);
|
|
414
|
+
const moviePath = ref<string | null>(null);
|
|
415
|
+
const beatAudios = reactive<Record<number, string>>({});
|
|
416
|
+
const audioState = reactive<Record<number, "generating" | "done" | "error">>({});
|
|
417
|
+
const audioErrors = reactive<Record<number, string>>({});
|
|
418
|
+
const playingAudio = ref<{ index: number; audio: HTMLAudioElement } | null>(null);
|
|
419
|
+
const beatListEl = ref<HTMLElement | null>(null);
|
|
420
|
+
const lightbox = ref<{
|
|
421
|
+
src: string;
|
|
422
|
+
text?: string;
|
|
423
|
+
index: number;
|
|
424
|
+
isCharacter?: boolean;
|
|
425
|
+
} | null>(null);
|
|
426
|
+
// Character (imageParams.images) state
|
|
427
|
+
type CharRenderState = "idle" | "rendering" | "done" | "error";
|
|
428
|
+
const charRenderState = reactive<Record<string, CharRenderState>>({});
|
|
429
|
+
const charImages = reactive<Record<string, string>>({});
|
|
430
|
+
const charErrors = reactive<Record<string, string>>({});
|
|
431
|
+
const charDragOver = reactive<Record<string, boolean>>({});
|
|
432
|
+
const beatDragOver = reactive<Record<number, boolean>>({});
|
|
433
|
+
|
|
434
|
+
const anyBeatRendering = computed(() => Object.values(renderState).some((s) => s === "rendering"));
|
|
435
|
+
|
|
436
|
+
const characterKeys = computed(() => {
|
|
437
|
+
const imgs = script.value.imageParams?.images ?? {};
|
|
438
|
+
return Object.keys(imgs).filter((key) => imgs[key]?.type === "imagePrompt");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Session-scoped pending generations — lets spinners survive view
|
|
442
|
+
// unmount/remount and tags new generations on the correct session
|
|
443
|
+
// channel so the cross-session sidebar indicator stays lit.
|
|
444
|
+
const activeSessionRef = useActiveSession();
|
|
445
|
+
const chatSessionId = computed(() => activeSessionRef?.value?.id);
|
|
446
|
+
|
|
447
|
+
const pendingForThisScript = computed(() => {
|
|
448
|
+
const out: Record<string, PendingGeneration> = {};
|
|
449
|
+
const pending = activeSessionRef?.value?.pendingGenerations ?? {};
|
|
450
|
+
const fp = filePath.value;
|
|
451
|
+
if (!fp) return out;
|
|
452
|
+
for (const [mapKey, entry] of Object.entries(pending)) {
|
|
453
|
+
if (entry.filePath === fp) out[mapKey] = entry;
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Local renderState / charRenderState / audioState / movieGenerating
|
|
459
|
+
// are kept in sync with `pendingForThisScript` by the watcher below
|
|
460
|
+
// and by `initializeScript`, so the template continues to read them
|
|
461
|
+
// without needing per-kind predicates here.
|
|
462
|
+
|
|
463
|
+
function characterPrompt(key: string): string {
|
|
464
|
+
return (script.value.imageParams?.images?.[key]?.prompt as string) ?? "";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function openLightbox(index: number) {
|
|
468
|
+
if (playingAudio.value) {
|
|
469
|
+
playingAudio.value.audio.pause();
|
|
470
|
+
playingAudio.value = null;
|
|
471
|
+
}
|
|
472
|
+
lightbox.value = {
|
|
473
|
+
src: renderedImages[index],
|
|
474
|
+
text: effectiveBeat(index).text,
|
|
475
|
+
index,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const hasPrev = computed(() => {
|
|
480
|
+
if (!lightbox.value) return false;
|
|
481
|
+
for (let i = lightbox.value.index - 1; i >= 0; i--) {
|
|
482
|
+
if (renderedImages[i]) return true;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const hasNext = computed(() => {
|
|
488
|
+
if (!lightbox.value) return false;
|
|
489
|
+
for (let i = lightbox.value.index + 1; i < beats.value.length; i++) {
|
|
490
|
+
if (renderedImages[i]) return true;
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
function lightboxMove(delta: number) {
|
|
496
|
+
if (!lightbox.value) return;
|
|
497
|
+
const total = beats.value.length;
|
|
498
|
+
let i = lightbox.value.index + delta;
|
|
499
|
+
while (i >= 0 && i < total) {
|
|
500
|
+
if (renderedImages[i]) {
|
|
501
|
+
openLightbox(i);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
i += delta;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const sourceDetails = ref<HTMLDetailsElement>();
|
|
508
|
+
const editing = ref(false);
|
|
509
|
+
const editableSource = ref("");
|
|
510
|
+
const { copied, copy } = useClipboardCopy();
|
|
511
|
+
|
|
512
|
+
// Beats may be edited in-place via `updateBeat()` and rendered through
|
|
513
|
+
// `effectiveBeat()`, so the Copy / source-view text must read the merged
|
|
514
|
+
// shape — otherwise the clipboard returns the original prop snapshot
|
|
515
|
+
// until the full result is reloaded.
|
|
516
|
+
const effectiveScript = computed<MulmoScript>(() => ({
|
|
517
|
+
...script.value,
|
|
518
|
+
beats: beats.value.map((beat, i) => localOverrides[i] ?? beat),
|
|
519
|
+
}));
|
|
520
|
+
const scriptSourceText = computed(() => JSON.stringify(effectiveScript.value, null, 2));
|
|
521
|
+
const loadedSource = ref("");
|
|
522
|
+
const sourceChanged = computed(() => editableSource.value !== loadedSource.value);
|
|
523
|
+
const sourceValid = computed(() => {
|
|
524
|
+
try {
|
|
525
|
+
const parsed = JSON.parse(editableSource.value);
|
|
526
|
+
return mulmoScriptSchema.safeParse(parsed).success;
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
async function onSourceToggle(open: boolean) {
|
|
533
|
+
editing.value = open;
|
|
534
|
+
if (open) {
|
|
535
|
+
let text = scriptSourceText.value;
|
|
536
|
+
// Read the current file from disk so beat-level edits are reflected
|
|
537
|
+
if (filePath.value) {
|
|
538
|
+
const response = await apiGet<{ content?: string }>(API_ROUTES.files.content, { path: filePath.value });
|
|
539
|
+
if (response.ok && response.data.content) {
|
|
540
|
+
text = response.data.content;
|
|
541
|
+
}
|
|
542
|
+
// fall through to in-memory script on failure
|
|
543
|
+
}
|
|
544
|
+
editableSource.value = text;
|
|
545
|
+
loadedSource.value = text;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function cancelSourceEdit() {
|
|
550
|
+
if (sourceDetails.value) sourceDetails.value.open = false;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function applySource() {
|
|
554
|
+
let parsed: MulmoScript;
|
|
555
|
+
try {
|
|
556
|
+
parsed = JSON.parse(editableSource.value);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
alert(extractErrorMessage(err));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateScript, {
|
|
562
|
+
filePath: filePath.value,
|
|
563
|
+
script: parsed,
|
|
564
|
+
});
|
|
565
|
+
if (!response.ok) {
|
|
566
|
+
alert(response.error || "Update failed");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Update the UI with the new script.
|
|
571
|
+
// Note: the parent's handleUpdateResult uses Object.assign (in-place
|
|
572
|
+
// mutation), so the watcher on props.selectedResult won't fire.
|
|
573
|
+
// We emit first so the parent data is updated, then manually
|
|
574
|
+
// re-initialize the view.
|
|
575
|
+
emit("updateResult", {
|
|
576
|
+
...props.selectedResult,
|
|
577
|
+
data: { ...props.selectedResult.data, script: parsed },
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (sourceDetails.value) sourceDetails.value.open = false;
|
|
581
|
+
await initializeScript();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function copyText() {
|
|
585
|
+
await copy(scriptSourceText.value);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function effectiveBeat(index: number): Beat {
|
|
589
|
+
return localOverrides[index] ?? beats.value[index] ?? {};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function toggleSource(index: number) {
|
|
593
|
+
if (!sourceOpen[index]) {
|
|
594
|
+
sourceText[index] = JSON.stringify(effectiveBeat(index), null, 2);
|
|
595
|
+
delete beatSaveErrors[index];
|
|
596
|
+
}
|
|
597
|
+
sourceOpen[index] = !sourceOpen[index];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isValidBeat(index: number): boolean {
|
|
601
|
+
return validateBeatJSON(sourceText[index] ?? "", mulmoBeatSchema);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function updateBeat(index: number) {
|
|
605
|
+
let beat: Beat;
|
|
606
|
+
try {
|
|
607
|
+
beat = JSON.parse(sourceText[index]);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
beatSaveErrors[index] = `Invalid JSON: ${errorMessage(err)}`;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const prevImage = JSON.stringify(effectiveBeat(index).image);
|
|
613
|
+
|
|
614
|
+
delete beatSaveErrors[index];
|
|
615
|
+
beatSaving[index] = true;
|
|
616
|
+
const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateBeat, {
|
|
617
|
+
filePath: filePath.value,
|
|
618
|
+
beatIndex: index,
|
|
619
|
+
beat,
|
|
620
|
+
});
|
|
621
|
+
delete beatSaving[index];
|
|
622
|
+
if (!response.ok) {
|
|
623
|
+
beatSaveErrors[index] = `Save failed: ${response.error}`;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
localOverrides[index] = beat;
|
|
628
|
+
sourceOpen[index] = false;
|
|
629
|
+
|
|
630
|
+
if (JSON.stringify(beat.image) !== prevImage) {
|
|
631
|
+
delete renderedImages[index];
|
|
632
|
+
renderBeat(index);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function renderBeat(index: number) {
|
|
637
|
+
renderState[index] = "rendering";
|
|
638
|
+
const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
|
|
639
|
+
filePath: filePath.value,
|
|
640
|
+
beatIndex: index,
|
|
641
|
+
chatSessionId: chatSessionId.value,
|
|
642
|
+
});
|
|
643
|
+
if (!response.ok) {
|
|
644
|
+
renderErrors[index] = response.error || "Render failed";
|
|
645
|
+
renderState[index] = "error";
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (response.data.error) {
|
|
649
|
+
renderErrors[index] = response.data.error;
|
|
650
|
+
renderState[index] = "error";
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
renderedImages[index] = response.data.image ?? "";
|
|
654
|
+
renderState[index] = "done";
|
|
655
|
+
refreshMissingCharacterImages();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function regenerateBeat(index: number) {
|
|
659
|
+
delete renderedImages[index];
|
|
660
|
+
renderState[index] = "rendering";
|
|
661
|
+
const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
|
|
662
|
+
filePath: filePath.value,
|
|
663
|
+
beatIndex: index,
|
|
664
|
+
force: true,
|
|
665
|
+
chatSessionId: chatSessionId.value,
|
|
666
|
+
});
|
|
667
|
+
if (!response.ok) {
|
|
668
|
+
renderErrors[index] = response.error || "Render failed";
|
|
669
|
+
renderState[index] = "error";
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (response.data.error) {
|
|
673
|
+
renderErrors[index] = response.data.error;
|
|
674
|
+
renderState[index] = "error";
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
renderedImages[index] = response.data.image ?? "";
|
|
678
|
+
renderState[index] = "done";
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function loadExistingBeatImage(index: number) {
|
|
682
|
+
const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.beatImage, { filePath: filePath.value, beatIndex: String(index) });
|
|
683
|
+
// silently ignore errors — image simply hasn't been generated yet
|
|
684
|
+
if (response.ok && response.data.image) {
|
|
685
|
+
renderedImages[index] = response.data.image;
|
|
686
|
+
renderState[index] = "done";
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function loadExistingBeatAudio(index: number) {
|
|
691
|
+
const response = await apiGet<{ audio?: string }>(API_ROUTES.mulmoScript.beatAudio, { filePath: filePath.value, beatIndex: String(index) });
|
|
692
|
+
// silently ignore errors
|
|
693
|
+
if (response.ok && response.data.audio) {
|
|
694
|
+
beatAudios[index] = response.data.audio;
|
|
695
|
+
audioState[index] = "done";
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function generateAudio(index: number) {
|
|
700
|
+
audioState[index] = "generating";
|
|
701
|
+
delete audioErrors[index];
|
|
702
|
+
const response = await apiPost<{ audio?: string; error?: string }>(API_ROUTES.mulmoScript.generateBeatAudio, {
|
|
703
|
+
filePath: filePath.value,
|
|
704
|
+
beatIndex: index,
|
|
705
|
+
chatSessionId: chatSessionId.value,
|
|
706
|
+
});
|
|
707
|
+
if (!response.ok) {
|
|
708
|
+
audioErrors[index] = response.error || "Audio generation failed";
|
|
709
|
+
audioState[index] = "error";
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (response.data.error) {
|
|
713
|
+
audioErrors[index] = response.data.error;
|
|
714
|
+
audioState[index] = "error";
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
beatAudios[index] = response.data.audio ?? "";
|
|
718
|
+
audioState[index] = "done";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function playAudio(index: number) {
|
|
722
|
+
if (playingAudio.value) {
|
|
723
|
+
playingAudio.value.audio.pause();
|
|
724
|
+
const wasIndex = playingAudio.value.index;
|
|
725
|
+
playingAudio.value = null;
|
|
726
|
+
if (wasIndex === index) return;
|
|
727
|
+
}
|
|
728
|
+
const src = beatAudios[index];
|
|
729
|
+
if (!src) return;
|
|
730
|
+
const audio = new Audio(src);
|
|
731
|
+
playingAudio.value = { index, audio };
|
|
732
|
+
audio.addEventListener("ended", () => {
|
|
733
|
+
if (playingAudio.value?.index !== index) return;
|
|
734
|
+
playingAudio.value = null;
|
|
735
|
+
if (lightbox.value?.index === index) {
|
|
736
|
+
lightboxMove(1);
|
|
737
|
+
const nextIndex = lightbox.value?.index;
|
|
738
|
+
if (nextIndex !== undefined && nextIndex !== index && beatAudios[nextIndex]) {
|
|
739
|
+
playAudio(nextIndex);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
audio.play();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function onBeatDragOver(event: DragEvent, index: number) {
|
|
747
|
+
if (!event.dataTransfer?.types.includes("Files")) return;
|
|
748
|
+
event.preventDefault();
|
|
749
|
+
beatDragOver[index] = true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function onBeatDragLeave(index: number) {
|
|
753
|
+
beatDragOver[index] = false;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function onBeatDrop(event: DragEvent, index: number) {
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
beatDragOver[index] = false;
|
|
759
|
+
const file = event.dataTransfer?.files[0];
|
|
760
|
+
if (!file || !file.type.startsWith("image/")) return;
|
|
761
|
+
|
|
762
|
+
renderState[index] = "rendering";
|
|
763
|
+
delete renderErrors[index];
|
|
764
|
+
let imageData: string;
|
|
765
|
+
try {
|
|
766
|
+
imageData = await new Promise<string>((resolve, reject) => {
|
|
767
|
+
const reader = new FileReader();
|
|
768
|
+
reader.onload = () => resolve(reader.result as string);
|
|
769
|
+
reader.onerror = reject;
|
|
770
|
+
reader.readAsDataURL(file);
|
|
771
|
+
});
|
|
772
|
+
} catch (err) {
|
|
773
|
+
renderErrors[index] = errorMessage(err);
|
|
774
|
+
renderState[index] = "error";
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadBeatImage, {
|
|
778
|
+
filePath: filePath.value,
|
|
779
|
+
beatIndex: index,
|
|
780
|
+
imageData,
|
|
781
|
+
});
|
|
782
|
+
if (!response.ok) {
|
|
783
|
+
renderErrors[index] = response.error || "Upload failed";
|
|
784
|
+
renderState[index] = "error";
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (response.data.error) {
|
|
788
|
+
renderErrors[index] = response.data.error;
|
|
789
|
+
renderState[index] = "error";
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
renderedImages[index] = response.data.image ?? "";
|
|
793
|
+
renderState[index] = "done";
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function onCharDragOver(event: DragEvent, key: string) {
|
|
797
|
+
if (!event.dataTransfer?.types.includes("Files")) return;
|
|
798
|
+
event.preventDefault();
|
|
799
|
+
charDragOver[key] = true;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function onCharDragLeave(key: string) {
|
|
803
|
+
charDragOver[key] = false;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function onCharDrop(event: DragEvent, key: string) {
|
|
807
|
+
event.preventDefault();
|
|
808
|
+
charDragOver[key] = false;
|
|
809
|
+
const file = event.dataTransfer?.files[0];
|
|
810
|
+
if (!file || !file.type.startsWith("image/")) return;
|
|
811
|
+
|
|
812
|
+
charRenderState[key] = "rendering";
|
|
813
|
+
delete charErrors[key];
|
|
814
|
+
let imageData: string;
|
|
815
|
+
try {
|
|
816
|
+
imageData = await new Promise<string>((resolve, reject) => {
|
|
817
|
+
const reader = new FileReader();
|
|
818
|
+
reader.onload = () => resolve(reader.result as string);
|
|
819
|
+
reader.onerror = reject;
|
|
820
|
+
reader.readAsDataURL(file);
|
|
821
|
+
});
|
|
822
|
+
} catch (err) {
|
|
823
|
+
charErrors[key] = errorMessage(err);
|
|
824
|
+
charRenderState[key] = "error";
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadCharacterImage, { filePath: filePath.value, key, imageData });
|
|
828
|
+
if (!response.ok) {
|
|
829
|
+
charErrors[key] = response.error || "Upload failed";
|
|
830
|
+
charRenderState[key] = "error";
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (response.data.error) {
|
|
834
|
+
charErrors[key] = response.data.error;
|
|
835
|
+
charRenderState[key] = "error";
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
charImages[key] = response.data.image ?? "";
|
|
839
|
+
charRenderState[key] = "done";
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function openCharacterLightbox(key: string) {
|
|
843
|
+
if (playingAudio.value) {
|
|
844
|
+
playingAudio.value.audio.pause();
|
|
845
|
+
playingAudio.value = null;
|
|
846
|
+
}
|
|
847
|
+
lightbox.value = {
|
|
848
|
+
src: charImages[key],
|
|
849
|
+
text: key,
|
|
850
|
+
index: -1,
|
|
851
|
+
isCharacter: true,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function loadExistingCharacterImage(key: string) {
|
|
856
|
+
const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.characterImage, { filePath: filePath.value, key });
|
|
857
|
+
// silently ignore errors
|
|
858
|
+
if (response.ok && response.data.image) {
|
|
859
|
+
charImages[key] = response.data.image;
|
|
860
|
+
charRenderState[key] = "done";
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function refreshMissingCharacterImages() {
|
|
865
|
+
getMissingCharacterKeys(characterKeys.value, charImages, charRenderState).forEach((key) => loadExistingCharacterImage(key));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function renderCharacter(key: string, force: boolean) {
|
|
869
|
+
charRenderState[key] = "rendering";
|
|
870
|
+
delete charErrors[key];
|
|
871
|
+
const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderCharacter, {
|
|
872
|
+
filePath: filePath.value,
|
|
873
|
+
key,
|
|
874
|
+
force,
|
|
875
|
+
chatSessionId: chatSessionId.value,
|
|
876
|
+
});
|
|
877
|
+
if (!response.ok) {
|
|
878
|
+
charErrors[key] = response.error || "Render failed";
|
|
879
|
+
charRenderState[key] = "error";
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (response.data.error) {
|
|
883
|
+
charErrors[key] = response.data.error;
|
|
884
|
+
charRenderState[key] = "error";
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
charImages[key] = response.data.image ?? "";
|
|
888
|
+
charRenderState[key] = "done";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function generateAllCharacters() {
|
|
892
|
+
await Promise.all(characterKeys.value.filter((key) => charRenderState[key] !== "rendering").map((key) => renderCharacter(key, false)));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function initializeScript() {
|
|
896
|
+
// Reset scroll position so new results start at the top
|
|
897
|
+
if (beatListEl.value) beatListEl.value.scrollTop = 0;
|
|
898
|
+
// Reset per-script state
|
|
899
|
+
Object.keys(renderState).forEach((key) => delete renderState[+key]);
|
|
900
|
+
Object.keys(renderedImages).forEach((key) => delete renderedImages[+key]);
|
|
901
|
+
Object.keys(renderErrors).forEach((key) => delete renderErrors[+key]);
|
|
902
|
+
Object.keys(sourceOpen).forEach((key) => delete sourceOpen[+key]);
|
|
903
|
+
Object.keys(sourceText).forEach((key) => delete sourceText[+key]);
|
|
904
|
+
Object.keys(beatSaveErrors).forEach((key) => delete beatSaveErrors[+key]);
|
|
905
|
+
Object.keys(beatSaving).forEach((key) => delete beatSaving[+key]);
|
|
906
|
+
Object.keys(localOverrides).forEach((key) => delete localOverrides[+key]);
|
|
907
|
+
Object.keys(beatAudios).forEach((key) => delete beatAudios[+key]);
|
|
908
|
+
Object.keys(audioState).forEach((key) => delete audioState[+key]);
|
|
909
|
+
Object.keys(audioErrors).forEach((key) => delete audioErrors[+key]);
|
|
910
|
+
Object.keys(charRenderState).forEach((key) => delete charRenderState[key]);
|
|
911
|
+
Object.keys(charImages).forEach((key) => delete charImages[key]);
|
|
912
|
+
Object.keys(charErrors).forEach((key) => delete charErrors[key]);
|
|
913
|
+
Object.keys(beatDragOver).forEach((key) => delete beatDragOver[+key]);
|
|
914
|
+
moviePath.value = null;
|
|
915
|
+
if (sourceDetails.value) sourceDetails.value.open = false;
|
|
916
|
+
|
|
917
|
+
const AUTO_RENDER_TYPES = ["textSlide", "markdown", "chart", "mermaid", "html_tailwind"] as const;
|
|
918
|
+
const hasCharacters = characterKeys.value.length > 0;
|
|
919
|
+
beats.value.forEach((beat, index) => {
|
|
920
|
+
if (shouldAutoRenderBeat(beat, hasCharacters, AUTO_RENDER_TYPES)) {
|
|
921
|
+
renderBeat(index);
|
|
922
|
+
} else if (beat.imagePrompt) {
|
|
923
|
+
loadExistingBeatImage(index);
|
|
924
|
+
}
|
|
925
|
+
if (beat.text) loadExistingBeatAudio(index);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
characterKeys.value.forEach((key) => loadExistingCharacterImage(key));
|
|
929
|
+
|
|
930
|
+
if (filePath.value) {
|
|
931
|
+
const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
|
|
932
|
+
if (response.ok && response.data.moviePath) {
|
|
933
|
+
moviePath.value = response.data.moviePath;
|
|
934
|
+
}
|
|
935
|
+
// ignore errors
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Reflect any generations that were already in flight when we
|
|
939
|
+
// mounted (user switched away mid-generation and came back).
|
|
940
|
+
for (const entry of Object.values(pendingForThisScript.value)) {
|
|
941
|
+
reflectGenerationStart(entry);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
onMounted(initializeScript);
|
|
946
|
+
watch(() => props.selectedResult, initializeScript);
|
|
947
|
+
|
|
948
|
+
// Keep the view in sync with generations that started from a different
|
|
949
|
+
// view mount or a parallel tab. When a generation for this script
|
|
950
|
+
// disappears from session.pendingGenerations we reload the relevant
|
|
951
|
+
// artifact off disk; when one appears we mirror it into the local
|
|
952
|
+
// "rendering" state so spinners show even after a remount.
|
|
953
|
+
watch(pendingForThisScript, (now, prev = {}) => {
|
|
954
|
+
for (const [mapKey, entry] of Object.entries(now)) {
|
|
955
|
+
if (!(mapKey in prev)) reflectGenerationStart(entry);
|
|
956
|
+
}
|
|
957
|
+
for (const [mapKey, entry] of Object.entries(prev)) {
|
|
958
|
+
if (!(mapKey in now)) {
|
|
959
|
+
// Fire-and-forget: the watcher callback must stay sync so Vue
|
|
960
|
+
// can batch multiple pendingGenerations updates. Swallow + log
|
|
961
|
+
// so a failed reload doesn't surface as an unhandled rejection.
|
|
962
|
+
reflectGenerationFinish(entry).catch((err) => {
|
|
963
|
+
console.error("[presentMulmoScript] reload on finish failed:", err);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
function reflectGenerationStart(entry: PendingGeneration): void {
|
|
970
|
+
if (entry.kind === GENERATION_KINDS.beatImage) {
|
|
971
|
+
const idx = Number(entry.key);
|
|
972
|
+
if (!renderedImages[idx]) renderState[idx] = "rendering";
|
|
973
|
+
} else if (entry.kind === GENERATION_KINDS.beatAudio) {
|
|
974
|
+
const idx = Number(entry.key);
|
|
975
|
+
if (!beatAudios[idx]) audioState[idx] = "generating";
|
|
976
|
+
} else if (entry.kind === GENERATION_KINDS.characterImage) {
|
|
977
|
+
if (!charImages[entry.key]) charRenderState[entry.key] = "rendering";
|
|
978
|
+
} else if (entry.kind === GENERATION_KINDS.movie) {
|
|
979
|
+
movieGenerating.value = true;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function reflectGenerationFinish(entry: PendingGeneration): Promise<void> {
|
|
984
|
+
if (entry.kind === GENERATION_KINDS.beatImage) {
|
|
985
|
+
const idx = Number(entry.key);
|
|
986
|
+
await loadExistingBeatImage(idx);
|
|
987
|
+
if (renderState[idx] === "rendering") delete renderState[idx];
|
|
988
|
+
} else if (entry.kind === GENERATION_KINDS.beatAudio) {
|
|
989
|
+
const idx = Number(entry.key);
|
|
990
|
+
await loadExistingBeatAudio(idx);
|
|
991
|
+
if (audioState[idx] === "generating") delete audioState[idx];
|
|
992
|
+
} else if (entry.kind === GENERATION_KINDS.characterImage) {
|
|
993
|
+
await loadExistingCharacterImage(entry.key);
|
|
994
|
+
if (charRenderState[entry.key] === "rendering") {
|
|
995
|
+
delete charRenderState[entry.key];
|
|
996
|
+
}
|
|
997
|
+
} else if (entry.kind === GENERATION_KINDS.movie) {
|
|
998
|
+
movieGenerating.value = false;
|
|
999
|
+
await refreshMoviePath();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function refreshMoviePath(): Promise<void> {
|
|
1004
|
+
if (!filePath.value) return;
|
|
1005
|
+
const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
|
|
1006
|
+
if (response.ok && response.data.moviePath) {
|
|
1007
|
+
moviePath.value = response.data.moviePath;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function generateMovie() {
|
|
1012
|
+
movieGenerating.value = true;
|
|
1013
|
+
try {
|
|
1014
|
+
const res = await apiFetchRaw(API_ROUTES.mulmoScript.generateMovie, {
|
|
1015
|
+
method: "POST",
|
|
1016
|
+
body: JSON.stringify({
|
|
1017
|
+
filePath: filePath.value,
|
|
1018
|
+
chatSessionId: chatSessionId.value,
|
|
1019
|
+
}),
|
|
1020
|
+
headers: { "Content-Type": "application/json" },
|
|
1021
|
+
});
|
|
1022
|
+
if (!res.ok || !res.body) throw new Error("Generation failed");
|
|
1023
|
+
await streamMovieEvents(res.body, {
|
|
1024
|
+
onBeatImageDone: (beatIndex) => {
|
|
1025
|
+
loadExistingBeatImage(beatIndex);
|
|
1026
|
+
refreshMissingCharacterImages();
|
|
1027
|
+
},
|
|
1028
|
+
onBeatAudioDone: (beatIndex) => loadExistingBeatAudio(beatIndex),
|
|
1029
|
+
onDone: (path) => {
|
|
1030
|
+
moviePath.value = path;
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
alert(extractErrorMessage(err));
|
|
1035
|
+
} finally {
|
|
1036
|
+
movieGenerating.value = false;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
</script>
|
|
1040
|
+
|
|
1041
|
+
<style scoped>
|
|
1042
|
+
.bottom-bar-wrapper {
|
|
1043
|
+
position: relative;
|
|
1044
|
+
flex-shrink: 0;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.script-source {
|
|
1048
|
+
padding: 0.5rem;
|
|
1049
|
+
background: #f5f5f5;
|
|
1050
|
+
border-top: 1px solid #e0e0e0;
|
|
1051
|
+
font-family: monospace;
|
|
1052
|
+
font-size: 0.85rem;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.script-source summary {
|
|
1056
|
+
cursor: pointer;
|
|
1057
|
+
user-select: none;
|
|
1058
|
+
padding: 0.5rem;
|
|
1059
|
+
background: #e8e8e8;
|
|
1060
|
+
border-radius: 4px;
|
|
1061
|
+
font-weight: 500;
|
|
1062
|
+
color: #333;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.script-source[open] summary {
|
|
1066
|
+
margin-bottom: 0.5rem;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
.script-source summary:hover {
|
|
1070
|
+
background: #d8d8d8;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.script-editor {
|
|
1074
|
+
width: 100%;
|
|
1075
|
+
height: 40vh;
|
|
1076
|
+
padding: 1rem;
|
|
1077
|
+
background: #ffffff;
|
|
1078
|
+
border: 1px solid #ccc;
|
|
1079
|
+
border-radius: 4px;
|
|
1080
|
+
color: #333;
|
|
1081
|
+
font-family: "Courier New", monospace;
|
|
1082
|
+
font-size: 0.9rem;
|
|
1083
|
+
resize: vertical;
|
|
1084
|
+
margin-bottom: 0.5rem;
|
|
1085
|
+
line-height: 1.5;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
.script-editor:focus {
|
|
1089
|
+
outline: none;
|
|
1090
|
+
border-color: #4caf50;
|
|
1091
|
+
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.script-editor-invalid {
|
|
1095
|
+
border-color: #ef4444;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
.script-editor-invalid:focus {
|
|
1099
|
+
border-color: #ef4444;
|
|
1100
|
+
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
.editor-actions {
|
|
1104
|
+
display: flex;
|
|
1105
|
+
justify-content: space-between;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.apply-btn {
|
|
1109
|
+
padding: 0.5rem 1rem;
|
|
1110
|
+
background: #4caf50;
|
|
1111
|
+
color: white;
|
|
1112
|
+
border: none;
|
|
1113
|
+
border-radius: 4px;
|
|
1114
|
+
cursor: pointer;
|
|
1115
|
+
font-size: 0.9rem;
|
|
1116
|
+
transition: background 0.2s;
|
|
1117
|
+
font-weight: 500;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
.apply-btn:hover {
|
|
1121
|
+
background: #45a049;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.apply-btn:disabled {
|
|
1125
|
+
background: #cccccc;
|
|
1126
|
+
color: #666666;
|
|
1127
|
+
cursor: not-allowed;
|
|
1128
|
+
opacity: 0.6;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.cancel-btn {
|
|
1132
|
+
padding: 0.5rem 1rem;
|
|
1133
|
+
background: #e0e0e0;
|
|
1134
|
+
color: #333;
|
|
1135
|
+
border: none;
|
|
1136
|
+
border-radius: 4px;
|
|
1137
|
+
cursor: pointer;
|
|
1138
|
+
font-size: 0.9rem;
|
|
1139
|
+
transition: background 0.2s;
|
|
1140
|
+
font-weight: 500;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.cancel-btn:hover {
|
|
1144
|
+
background: #d0d0d0;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.copy-btn {
|
|
1148
|
+
position: absolute;
|
|
1149
|
+
bottom: 0.3rem;
|
|
1150
|
+
right: 0.65rem;
|
|
1151
|
+
padding: 0.4rem;
|
|
1152
|
+
background: none;
|
|
1153
|
+
border: none;
|
|
1154
|
+
color: #333;
|
|
1155
|
+
cursor: pointer;
|
|
1156
|
+
z-index: 1;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.copy-btn:hover {
|
|
1160
|
+
color: #000;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
.copy-btn .material-icons {
|
|
1164
|
+
font-size: 1.15rem;
|
|
1165
|
+
}
|
|
1166
|
+
</style>
|