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,712 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
5
|
+
import { stripDataUri } from "../../utils/files/image-store.js";
|
|
6
|
+
import {
|
|
7
|
+
getFileObject,
|
|
8
|
+
initializeContextFromFiles,
|
|
9
|
+
generateBeatImage,
|
|
10
|
+
getBeatPngImagePath,
|
|
11
|
+
generateBeatAudio,
|
|
12
|
+
getBeatAudioPathOrUrl,
|
|
13
|
+
generateReferenceImage,
|
|
14
|
+
getReferenceImagePath,
|
|
15
|
+
images,
|
|
16
|
+
audio,
|
|
17
|
+
movie,
|
|
18
|
+
movieFilePath,
|
|
19
|
+
setGraphAILogger,
|
|
20
|
+
addSessionProgressCallback,
|
|
21
|
+
removeSessionProgressCallback,
|
|
22
|
+
type MulmoScript,
|
|
23
|
+
} from "mulmocast";
|
|
24
|
+
import type { MulmoBeat, MulmoImagePromptMedia } from "@mulmocast/types";
|
|
25
|
+
import { slugify } from "../../utils/slug.js";
|
|
26
|
+
import { resolveWithinRoot } from "../../utils/files/safe.js";
|
|
27
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
28
|
+
import { badRequest, notFound, serverError } from "../../utils/httpError.js";
|
|
29
|
+
import { log } from "../../system/logger/index.js";
|
|
30
|
+
import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
|
|
31
|
+
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
32
|
+
import { publishGeneration } from "../../events/session-store/index.js";
|
|
33
|
+
import { GENERATION_KINDS } from "../../../src/types/events.js";
|
|
34
|
+
|
|
35
|
+
const router = Router();
|
|
36
|
+
const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
|
|
37
|
+
|
|
38
|
+
// The downloadMovie handler expects "stories/<rel>" (historical
|
|
39
|
+
// convention, independent of the on-disk location). After #284 the
|
|
40
|
+
// physical directory moved under artifacts/, so we can't return
|
|
41
|
+
// path.relative(workspacePath, ...) any more — that now begins with
|
|
42
|
+
// "artifacts/stories/". Re-rooting the path at storiesDir keeps the
|
|
43
|
+
// wire format stable.
|
|
44
|
+
function toStoryRef(absolutePath: string): string {
|
|
45
|
+
const rel = path.relative(storiesDir, absolutePath).split(path.sep).join("/");
|
|
46
|
+
return rel ? `stories/${rel}` : "stories";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Lazily realpath the stories dir on first use. We can't realpath at
|
|
50
|
+
// module load because the directory may not exist yet (it's created
|
|
51
|
+
// on demand by /mulmo-script POST). The cache is invalidated never —
|
|
52
|
+
// once the dir exists, its realpath is stable.
|
|
53
|
+
let storiesRealCache: string | null = null;
|
|
54
|
+
function ensureStoriesReal(): string | null {
|
|
55
|
+
if (storiesRealCache) return storiesRealCache;
|
|
56
|
+
try {
|
|
57
|
+
fs.mkdirSync(storiesDir, { recursive: true });
|
|
58
|
+
storiesRealCache = fs.realpathSync(storiesDir);
|
|
59
|
+
return storiesRealCache;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SaveMulmoScriptBody {
|
|
66
|
+
script: MulmoScript;
|
|
67
|
+
filename?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RenderBeatBody {
|
|
71
|
+
filePath: string;
|
|
72
|
+
beatIndex: number;
|
|
73
|
+
force?: boolean;
|
|
74
|
+
chatSessionId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface UploadBeatImageBody {
|
|
78
|
+
filePath: string;
|
|
79
|
+
beatIndex: number;
|
|
80
|
+
imageData: string; // base64 data URI
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ErrorResponse = { error: string };
|
|
84
|
+
|
|
85
|
+
type BeatImageResponse = { image: string | null } | ErrorResponse;
|
|
86
|
+
type BeatAudioResponse = { audio: string | null } | ErrorResponse;
|
|
87
|
+
type MovieStatusResponse = { moviePath: string | null } | ErrorResponse;
|
|
88
|
+
type GenerateBeatAudioResponse = { audio: string } | ErrorResponse;
|
|
89
|
+
|
|
90
|
+
interface BeatQuery {
|
|
91
|
+
filePath?: string;
|
|
92
|
+
beatIndex?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface FilePathQuery {
|
|
96
|
+
filePath?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
|
|
100
|
+
const { script, filename } = req.body;
|
|
101
|
+
|
|
102
|
+
if (!script || !Array.isArray(script.beats)) {
|
|
103
|
+
badRequest(res, "script with beats array is required");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fs.mkdirSync(storiesDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
const title = script.title || "untitled";
|
|
110
|
+
const slug = filename ? filename.replace(/\.json$/, "") : slugify(title);
|
|
111
|
+
const fname = `${slug}-${Date.now()}.json`;
|
|
112
|
+
const filePath = path.join(storiesDir, fname);
|
|
113
|
+
|
|
114
|
+
fs.writeFileSync(filePath, JSON.stringify(script, null, 2));
|
|
115
|
+
|
|
116
|
+
res.json({
|
|
117
|
+
data: { script, filePath: `stories/${fname}` },
|
|
118
|
+
message: `Saved MulmoScript to stories/${fname}`,
|
|
119
|
+
instructions: "Display the storyboard to the user.",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unknown>, res: Response) => {
|
|
124
|
+
const validation = validateUpdateBeatBody(req.body);
|
|
125
|
+
if (!validation.ok) {
|
|
126
|
+
badRequest(res, validation.error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const { filePath, beatIndex, beat } = validation.value;
|
|
130
|
+
|
|
131
|
+
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
132
|
+
if (!absoluteFilePath) return;
|
|
133
|
+
|
|
134
|
+
const script: MulmoScript = JSON.parse(fs.readFileSync(absoluteFilePath, "utf-8"));
|
|
135
|
+
|
|
136
|
+
if (!Array.isArray(script.beats) || beatIndex >= script.beats.length) {
|
|
137
|
+
badRequest(res, "Invalid beatIndex");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
script.beats[beatIndex] = beat as MulmoBeat;
|
|
142
|
+
fs.writeFileSync(absoluteFilePath, JSON.stringify(script, null, 2));
|
|
143
|
+
|
|
144
|
+
res.json({ ok: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, unknown>, res: Response) => {
|
|
148
|
+
const validation = validateUpdateScriptBody(req.body);
|
|
149
|
+
if (!validation.ok) {
|
|
150
|
+
badRequest(res, validation.error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const { filePath, script: updatedScript } = validation.value;
|
|
154
|
+
|
|
155
|
+
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
156
|
+
if (!absoluteFilePath) return;
|
|
157
|
+
|
|
158
|
+
fs.writeFileSync(absoluteFilePath, JSON.stringify(updatedScript, null, 2));
|
|
159
|
+
res.json({ ok: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatImageResponse, object, BeatQuery>, res: Response<BeatImageResponse>) => {
|
|
163
|
+
const { filePath, beatIndex: beatIndexStr } = req.query;
|
|
164
|
+
const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
|
|
165
|
+
|
|
166
|
+
if (!filePath || beatIndex === undefined || isNaN(beatIndex)) {
|
|
167
|
+
badRequest(res, "filePath and beatIndex are required");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
172
|
+
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
173
|
+
if (!fs.existsSync(imagePath)) {
|
|
174
|
+
res.json({ image: null });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, MovieStatusResponse, object, FilePathQuery>, res: Response<MovieStatusResponse>) => {
|
|
182
|
+
const { filePath } = req.query;
|
|
183
|
+
|
|
184
|
+
if (!filePath) {
|
|
185
|
+
badRequest(res, "filePath is required");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
190
|
+
if (!absoluteFilePath) return;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const context = await buildContext(absoluteFilePath);
|
|
194
|
+
if (!context) {
|
|
195
|
+
res.json({ moviePath: null });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const outputPath = movieFilePath(context);
|
|
200
|
+
if (!fs.existsSync(outputPath)) {
|
|
201
|
+
res.json({ moviePath: null });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const movieMtime = fs.statSync(outputPath).mtimeMs;
|
|
206
|
+
const sourceMtime = fs.statSync(absoluteFilePath).mtimeMs;
|
|
207
|
+
if (movieMtime < sourceMtime) {
|
|
208
|
+
res.json({ moviePath: null });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
res.json({ moviePath: toStoryRef(outputPath) });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
serverError(res, errorMessage(err));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function fileToDataUri(filePath: string, mimeType: string): string {
|
|
219
|
+
const data = fs.readFileSync(filePath);
|
|
220
|
+
return `data:${mimeType};base64,${data.toString("base64")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Helper: resolve and validate a stories filePath, returns absoluteFilePath or null
|
|
224
|
+
//
|
|
225
|
+
// Uses the realpath-based resolveWithinRoot helper to defeat
|
|
226
|
+
// symlink-based escapes. The previous implementation used a plain
|
|
227
|
+
// `path.resolve` + `startsWith` check, which a malicious symlink
|
|
228
|
+
// under stories/ could bypass.
|
|
229
|
+
//
|
|
230
|
+
// Callers pass workspace-relative paths like "stories/foo.json" or
|
|
231
|
+
// "stories/__movies__/bar.mp4". We strip the leading "stories/"
|
|
232
|
+
// segment and resolve the remainder against the realpath of the
|
|
233
|
+
// stories directory itself — this works whether stories/ is a
|
|
234
|
+
// regular directory or a legitimate symlink to another location
|
|
235
|
+
// (e.g. workspace/stories → /ext/stories on a different disk).
|
|
236
|
+
function resolveStoryPath(filePath: string, res: Response): string | null {
|
|
237
|
+
const storiesReal = ensureStoriesReal();
|
|
238
|
+
if (!storiesReal) {
|
|
239
|
+
serverError(res, "stories directory not available");
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// Reject absolute paths and parent traversal at the syntactic
|
|
243
|
+
// level — defense in depth on top of the realpath check below.
|
|
244
|
+
if (path.isAbsolute(filePath)) {
|
|
245
|
+
badRequest(res, "Invalid filePath");
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
// Strip the optional "stories/" prefix so the remainder is a path
|
|
249
|
+
// relative to storiesReal. Accepts both "stories/foo.json" (the
|
|
250
|
+
// canonical caller convention) and bare "foo.json".
|
|
251
|
+
const STORIES_PREFIX = "stories" + path.sep;
|
|
252
|
+
const relFromStories =
|
|
253
|
+
filePath === "stories" ? "" : filePath.startsWith(STORIES_PREFIX) || filePath.startsWith("stories/") ? filePath.slice("stories/".length) : filePath;
|
|
254
|
+
// resolveWithinRoot enforces both the realpath boundary AND
|
|
255
|
+
// existence; ENOENT and traversal both produce null. Distinguish
|
|
256
|
+
// them via a follow-up existsSync so 404 vs 400 stays accurate.
|
|
257
|
+
const resolved = resolveWithinRoot(storiesReal, relFromStories);
|
|
258
|
+
if (!resolved) {
|
|
259
|
+
const candidate = path.resolve(storiesReal, relFromStories);
|
|
260
|
+
if (!fs.existsSync(candidate)) {
|
|
261
|
+
notFound(res, `File not found: ${filePath}`);
|
|
262
|
+
} else {
|
|
263
|
+
badRequest(res, "Invalid filePath");
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return resolved;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Helper: build mulmo context for a story file
|
|
271
|
+
async function buildContext(absoluteFilePath: string, force = false) {
|
|
272
|
+
setGraphAILogger(false);
|
|
273
|
+
const files = getFileObject({
|
|
274
|
+
file: absoluteFilePath,
|
|
275
|
+
basedir: path.dirname(absoluteFilePath),
|
|
276
|
+
grouped: true,
|
|
277
|
+
});
|
|
278
|
+
return initializeContextFromFiles(files, true, force);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Awaited context type used by every helper that calls buildContext.
|
|
282
|
+
type StoryContext = NonNullable<Awaited<ReturnType<typeof buildContext>>>;
|
|
283
|
+
|
|
284
|
+
interface WithStoryContextDeps {
|
|
285
|
+
resolveStoryPath?: (filePath: string, res: Response) => string | null;
|
|
286
|
+
buildContext?: (absoluteFilePath: string, force?: boolean) => Promise<StoryContext | undefined>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Shared scaffolding for mulmo-script handlers. Each handler resolves
|
|
290
|
+
// the workspace-relative filePath, builds the mulmo context, and
|
|
291
|
+
// catches unexpected errors with a 500 + errorMessage. Extracted so
|
|
292
|
+
// every handler can focus on its own business logic.
|
|
293
|
+
//
|
|
294
|
+
// Accepts a `deps` param so unit tests can inject fakes without the
|
|
295
|
+
// full mulmocast stack.
|
|
296
|
+
export interface WithStoryContextOptions {
|
|
297
|
+
force?: boolean;
|
|
298
|
+
/**
|
|
299
|
+
* Handler-specific tag included in the helper's failure log so
|
|
300
|
+
* dashboards can distinguish which route is failing (e.g.
|
|
301
|
+
* `"generate-beat-audio"`). Falls back to a generic
|
|
302
|
+
* `"handler failed"` entry when omitted.
|
|
303
|
+
*/
|
|
304
|
+
operation?: string;
|
|
305
|
+
/**
|
|
306
|
+
* Soft-fail override for `buildContext` returning undefined. Some
|
|
307
|
+
* endpoints (e.g. `GET /beat-audio`) historically returned a
|
|
308
|
+
* 200 `{ audio: null }` in that case so the frontend can silently
|
|
309
|
+
* retry. If provided, this callback writes the fallback response
|
|
310
|
+
* instead of the default 500 `{ error: "Failed to initialize
|
|
311
|
+
* mulmo context" }`.
|
|
312
|
+
*/
|
|
313
|
+
onContextMissing?: (res: Response) => void;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function withStoryContext(
|
|
317
|
+
res: Response,
|
|
318
|
+
filePath: string,
|
|
319
|
+
options: WithStoryContextOptions,
|
|
320
|
+
handler: (ctx: { absoluteFilePath: string; context: StoryContext }) => Promise<void>,
|
|
321
|
+
deps: WithStoryContextDeps = {},
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const resolver = deps.resolveStoryPath ?? resolveStoryPath;
|
|
324
|
+
const build = deps.buildContext ?? buildContext;
|
|
325
|
+
const absoluteFilePath = resolver(filePath, res);
|
|
326
|
+
if (!absoluteFilePath) return;
|
|
327
|
+
try {
|
|
328
|
+
const context = await build(absoluteFilePath, options.force ?? false);
|
|
329
|
+
if (!context) {
|
|
330
|
+
if (options.onContextMissing) {
|
|
331
|
+
options.onContextMissing(res);
|
|
332
|
+
} else {
|
|
333
|
+
serverError(res, "Failed to initialize mulmo context");
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
await handler({ absoluteFilePath, context });
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Log every handler failure at warn so operators get a breadcrumb
|
|
340
|
+
// even when the migrated handler doesn't wrap its own try/catch.
|
|
341
|
+
// Consistent with the chat-index / wiki-backlinks / journal
|
|
342
|
+
// fire-and-forget error pattern.
|
|
343
|
+
log.warn("mulmo-script", "handler failed", {
|
|
344
|
+
...(options.operation ? { operation: options.operation } : {}),
|
|
345
|
+
filePath,
|
|
346
|
+
error: errorMessage(err),
|
|
347
|
+
});
|
|
348
|
+
// Double-write guard: if the handler has already started streaming
|
|
349
|
+
// or sent a partial response, appending a 500 body here would
|
|
350
|
+
// trigger Express's "Cannot set headers after they are sent"
|
|
351
|
+
// warning and corrupt the on-wire response.
|
|
352
|
+
if (!res.headersSent) {
|
|
353
|
+
serverError(res, errorMessage(err));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAudioResponse, object, BeatQuery>, res: Response<BeatAudioResponse>) => {
|
|
359
|
+
const { filePath, beatIndex: beatIndexStr } = req.query;
|
|
360
|
+
const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
|
|
361
|
+
|
|
362
|
+
if (!filePath || beatIndex === undefined || isNaN(beatIndex)) {
|
|
363
|
+
badRequest(res, "filePath and beatIndex are required");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// GET /beat-audio is a probe — the frontend polls it expecting a
|
|
368
|
+
// 200 with `{ audio: null }` when nothing has been generated yet.
|
|
369
|
+
// Override the helper's default 500-on-context-missing so the
|
|
370
|
+
// soft-fail contract is preserved.
|
|
371
|
+
await withStoryContext(
|
|
372
|
+
res,
|
|
373
|
+
filePath,
|
|
374
|
+
{
|
|
375
|
+
operation: "beat-audio",
|
|
376
|
+
onContextMissing: (r) => r.json({ audio: null }),
|
|
377
|
+
},
|
|
378
|
+
async ({ context }) => {
|
|
379
|
+
const beat = context.studio.script.beats[beatIndex];
|
|
380
|
+
const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
|
|
381
|
+
if (!audioPath || !fs.existsSync(audioPath)) {
|
|
382
|
+
res.json({ audio: null });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
res.json({ audio: fileToDataUri(audioPath, "audio/mpeg") });
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
router.post(
|
|
391
|
+
API_ROUTES.mulmoScript.generateBeatAudio,
|
|
392
|
+
async (
|
|
393
|
+
req: Request<
|
|
394
|
+
object,
|
|
395
|
+
object,
|
|
396
|
+
{
|
|
397
|
+
filePath: string;
|
|
398
|
+
beatIndex: number;
|
|
399
|
+
force?: boolean;
|
|
400
|
+
chatSessionId?: string;
|
|
401
|
+
}
|
|
402
|
+
>,
|
|
403
|
+
res: Response<GenerateBeatAudioResponse>,
|
|
404
|
+
) => {
|
|
405
|
+
const { filePath, beatIndex, force, chatSessionId } = req.body;
|
|
406
|
+
|
|
407
|
+
if (!filePath || beatIndex === undefined) {
|
|
408
|
+
badRequest(res, "filePath and beatIndex are required");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const key = String(beatIndex);
|
|
413
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.beatAudio, filePath, key, false);
|
|
414
|
+
let genError: string | undefined;
|
|
415
|
+
try {
|
|
416
|
+
await withStoryContext(res, filePath, { force, operation: "generate-beat-audio" }, async ({ context }) => {
|
|
417
|
+
try {
|
|
418
|
+
await generateBeatAudio(beatIndex, context, {
|
|
419
|
+
settings: process.env as Record<string, string>,
|
|
420
|
+
} as Parameters<typeof generateBeatAudio>[2]);
|
|
421
|
+
|
|
422
|
+
const beat = context.studio.script.beats[beatIndex];
|
|
423
|
+
const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
|
|
424
|
+
|
|
425
|
+
if (!audioPath || !fs.existsSync(audioPath)) {
|
|
426
|
+
// Logic-flow failure (not an exception) — emit a targeted
|
|
427
|
+
// log. Don't write raw `beat.text` into persistent logs —
|
|
428
|
+
// it's free-form user content and can contain sensitive
|
|
429
|
+
// data.
|
|
430
|
+
log.error("generate-beat-audio", "audio was not generated", {
|
|
431
|
+
beatIndex,
|
|
432
|
+
audioPath,
|
|
433
|
+
exists: audioPath ? fs.existsSync(audioPath) : false,
|
|
434
|
+
beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
|
|
435
|
+
audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile),
|
|
436
|
+
});
|
|
437
|
+
genError = "Audio was not generated";
|
|
438
|
+
serverError(res, genError);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
res.json({ audio: fileToDataUri(audioPath, "audio/mpeg") });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
genError = errorMessage(err);
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
} finally {
|
|
449
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.beatAudio, filePath, key, true, genError);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, object, RenderBeatBody>, res: Response) => {
|
|
455
|
+
const { filePath, beatIndex, force, chatSessionId } = req.body;
|
|
456
|
+
|
|
457
|
+
if (!filePath || beatIndex === undefined) {
|
|
458
|
+
badRequest(res, "filePath and beatIndex are required");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const key = String(beatIndex);
|
|
463
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.beatImage, filePath, key, false);
|
|
464
|
+
// withStoryContext swallows errors and responds with 500, so we
|
|
465
|
+
// track failure via a local flag / message rather than try/catch
|
|
466
|
+
// around the outer call.
|
|
467
|
+
let genError: string | undefined;
|
|
468
|
+
try {
|
|
469
|
+
await withStoryContext(res, filePath, { force }, async ({ context }) => {
|
|
470
|
+
try {
|
|
471
|
+
await generateBeatImage({
|
|
472
|
+
index: beatIndex,
|
|
473
|
+
context,
|
|
474
|
+
args: force ? { forceImage: true } : undefined,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
478
|
+
if (!fs.existsSync(imagePath)) {
|
|
479
|
+
genError = "Image was not generated";
|
|
480
|
+
serverError(res, genError);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
484
|
+
} catch (err) {
|
|
485
|
+
genError = errorMessage(err);
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
} finally {
|
|
490
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.beatImage, filePath, key, true, genError);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response) => {
|
|
495
|
+
const { filePath, chatSessionId } = req.body;
|
|
496
|
+
|
|
497
|
+
if (!filePath) {
|
|
498
|
+
badRequest(res, "filePath is required");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
503
|
+
if (!absoluteFilePath) return;
|
|
504
|
+
|
|
505
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
506
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
507
|
+
res.setHeader("Connection", "keep-alive");
|
|
508
|
+
|
|
509
|
+
const send = (data: unknown) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
510
|
+
|
|
511
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", false);
|
|
512
|
+
let genError: string | undefined;
|
|
513
|
+
try {
|
|
514
|
+
const context = await buildContext(absoluteFilePath);
|
|
515
|
+
if (!context) {
|
|
516
|
+
genError = "Failed to initialize mulmo context";
|
|
517
|
+
send({ type: "error", message: genError });
|
|
518
|
+
res.end();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Build id → beatIndex map for the progress callback
|
|
523
|
+
const idToIndex = new Map<string, number>();
|
|
524
|
+
(context.studio.script.beats as MulmoBeat[]).forEach((beat, index) => {
|
|
525
|
+
const key = beat.id ?? `__index__${index}`;
|
|
526
|
+
idToIndex.set(key, index);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const onProgress = (event: { kind: string; sessionType: string; id?: string; inSession: boolean }) => {
|
|
530
|
+
if (event.kind === "beat" && !event.inSession && event.id !== undefined) {
|
|
531
|
+
const beatIndex = idToIndex.get(event.id);
|
|
532
|
+
if (beatIndex !== undefined) {
|
|
533
|
+
send({ type: `beat_${event.sessionType}_done`, beatIndex });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
addSessionProgressCallback(onProgress);
|
|
539
|
+
try {
|
|
540
|
+
const imagesContext = await images(context);
|
|
541
|
+
const audioContext = await audio(imagesContext);
|
|
542
|
+
await movie(audioContext);
|
|
543
|
+
|
|
544
|
+
const outputPath = movieFilePath(audioContext);
|
|
545
|
+
if (!fs.existsSync(outputPath)) {
|
|
546
|
+
genError = "Movie was not generated";
|
|
547
|
+
send({ type: "error", message: genError });
|
|
548
|
+
res.end();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
send({ type: "done", moviePath: toStoryRef(outputPath) });
|
|
553
|
+
} finally {
|
|
554
|
+
removeSessionProgressCallback(onProgress);
|
|
555
|
+
}
|
|
556
|
+
} catch (err) {
|
|
557
|
+
genError = errorMessage(err);
|
|
558
|
+
send({ type: "error", message: genError });
|
|
559
|
+
} finally {
|
|
560
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", true, genError);
|
|
561
|
+
res.end();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
interface CharacterImageQuery {
|
|
566
|
+
filePath?: string;
|
|
567
|
+
key?: string;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interface RenderCharacterBody {
|
|
571
|
+
filePath: string;
|
|
572
|
+
key: string;
|
|
573
|
+
force?: boolean;
|
|
574
|
+
chatSessionId?: string;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
interface UploadCharacterImageBody {
|
|
578
|
+
filePath: string;
|
|
579
|
+
key: string;
|
|
580
|
+
imageData: string; // base64 data URI
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
type CharacterImageResponse = { image: string | null } | ErrorResponse;
|
|
584
|
+
|
|
585
|
+
router.get(
|
|
586
|
+
API_ROUTES.mulmoScript.characterImage,
|
|
587
|
+
async (req: Request<object, CharacterImageResponse, object, CharacterImageQuery>, res: Response<CharacterImageResponse>) => {
|
|
588
|
+
const { filePath, key } = req.query;
|
|
589
|
+
|
|
590
|
+
if (!filePath || !key) {
|
|
591
|
+
badRequest(res, "filePath and key are required");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
596
|
+
const imagePath = getReferenceImagePath(context, key, "png");
|
|
597
|
+
if (!fs.existsSync(imagePath)) {
|
|
598
|
+
res.json({ image: null });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
602
|
+
});
|
|
603
|
+
},
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
router.post(API_ROUTES.mulmoScript.uploadBeatImage, async (req: Request<object, BeatImageResponse, UploadBeatImageBody>, res: Response<BeatImageResponse>) => {
|
|
607
|
+
const { filePath, beatIndex, imageData } = req.body;
|
|
608
|
+
|
|
609
|
+
if (!filePath || beatIndex === undefined || !imageData) {
|
|
610
|
+
badRequest(res, "filePath, beatIndex, and imageData are required");
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
615
|
+
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
616
|
+
fs.mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
617
|
+
|
|
618
|
+
const base64 = stripDataUri(imageData);
|
|
619
|
+
fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
|
|
620
|
+
|
|
621
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
router.post(
|
|
626
|
+
API_ROUTES.mulmoScript.renderCharacter,
|
|
627
|
+
async (req: Request<object, CharacterImageResponse, RenderCharacterBody>, res: Response<CharacterImageResponse>) => {
|
|
628
|
+
const { filePath, key, force, chatSessionId } = req.body;
|
|
629
|
+
|
|
630
|
+
if (!filePath || !key) {
|
|
631
|
+
badRequest(res, "filePath and key are required");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.characterImage, filePath, key, false);
|
|
636
|
+
let genError: string | undefined;
|
|
637
|
+
try {
|
|
638
|
+
await withStoryContext(res, filePath, { force }, async ({ context }) => {
|
|
639
|
+
try {
|
|
640
|
+
const images = context.studio.script.imageParams?.images ?? {};
|
|
641
|
+
const imageEntry = images[key];
|
|
642
|
+
if (!imageEntry || imageEntry.type !== "imagePrompt") {
|
|
643
|
+
genError = `No imagePrompt entry for key: ${key}`;
|
|
644
|
+
badRequest(res, genError);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const index = Object.keys(images).indexOf(key);
|
|
649
|
+
const imagePath = getReferenceImagePath(context, key, "png");
|
|
650
|
+
fs.mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
651
|
+
|
|
652
|
+
await generateReferenceImage({
|
|
653
|
+
context,
|
|
654
|
+
key,
|
|
655
|
+
index,
|
|
656
|
+
image: imageEntry as MulmoImagePromptMedia,
|
|
657
|
+
force,
|
|
658
|
+
});
|
|
659
|
+
if (!fs.existsSync(imagePath)) {
|
|
660
|
+
genError = "Character image was not generated";
|
|
661
|
+
serverError(res, genError);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
665
|
+
} catch (err) {
|
|
666
|
+
genError = errorMessage(err);
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
} finally {
|
|
671
|
+
publishGeneration(chatSessionId, GENERATION_KINDS.characterImage, filePath, key, true, genError);
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
router.post(
|
|
677
|
+
API_ROUTES.mulmoScript.uploadCharacterImage,
|
|
678
|
+
async (req: Request<object, CharacterImageResponse, UploadCharacterImageBody>, res: Response<CharacterImageResponse>) => {
|
|
679
|
+
const { filePath, key, imageData } = req.body;
|
|
680
|
+
|
|
681
|
+
if (!filePath || !key || !imageData) {
|
|
682
|
+
badRequest(res, "filePath, key, and imageData are required");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
687
|
+
const imagePath = getReferenceImagePath(context, key, "png");
|
|
688
|
+
fs.mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
689
|
+
|
|
690
|
+
const base64 = stripDataUri(imageData);
|
|
691
|
+
fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
|
|
692
|
+
|
|
693
|
+
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
694
|
+
});
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
router.get(API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
|
|
699
|
+
const moviePath = typeof req.query.moviePath === "string" ? req.query.moviePath : undefined;
|
|
700
|
+
|
|
701
|
+
if (!moviePath) {
|
|
702
|
+
badRequest(res, "moviePath is required");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const absolutePath = resolveStoryPath(moviePath, res);
|
|
707
|
+
if (!absolutePath) return;
|
|
708
|
+
|
|
709
|
+
res.download(absolutePath);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
export default router;
|