onbuzz 4.9.13 → 4.10.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/node_modules/glob/README.md +31 -5
- package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/glob.js +2 -1
- package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
- package/node_modules/glob/dist/commonjs/index.min.js +3 -3
- package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/pattern.js +4 -0
- package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
- package/node_modules/glob/dist/esm/glob.d.ts +8 -0
- package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/glob.js +2 -1
- package/node_modules/glob/dist/esm/glob.js.map +1 -1
- package/node_modules/glob/dist/esm/index.min.js +3 -3
- package/node_modules/glob/dist/esm/index.min.js.map +4 -4
- package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
- package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/pattern.js +4 -0
- package/node_modules/glob/dist/esm/pattern.js.map +1 -1
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
- package/node_modules/glob/node_modules/minimatch/README.md +76 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/package.json +17 -11
- package/node_modules/glob/package.json +10 -13
- package/node_modules/minipass/LICENSE.md +55 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
- package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/commonjs/index.js +13 -3
- package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
- package/node_modules/minipass/dist/esm/index.d.ts +12 -16
- package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/esm/index.js +3 -1
- package/node_modules/minipass/dist/esm/index.js.map +1 -1
- package/node_modules/minipass/package.json +9 -14
- package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
- package/node_modules/path-scurry/package.json +8 -24
- package/package.json +1 -1
- package/scripts/debug-balance-probe.mjs +35 -35
- package/scripts/push-image.sh +43 -43
- package/scripts/setup-acr.sh +65 -65
- package/scripts/verify-optional-deps.js +96 -1
- package/src/__tests__/composioCliFlags.test.js +239 -239
- package/src/analyzers/CSSAnalyzer.js +298 -297
- package/src/analyzers/ConfigValidator.js +691 -690
- package/src/analyzers/ESLintAnalyzer.js +320 -320
- package/src/analyzers/JavaScriptAnalyzer.js +260 -261
- package/src/analyzers/PrettierFormatter.js +246 -247
- package/src/analyzers/PythonAnalyzer.js +283 -283
- package/src/analyzers/SecurityAnalyzer.js +729 -729
- package/src/analyzers/SparrowAnalyzer.js +341 -341
- package/src/analyzers/TypeScriptAnalyzer.js +247 -247
- package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
- package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
- package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
- package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
- package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
- package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
- package/src/analyzers/codeCloneDetector/detector.js +250 -250
- package/src/analyzers/codeCloneDetector/index.js +194 -192
- package/src/analyzers/codeCloneDetector/parser.js +199 -199
- package/src/core/__tests__/agentPool.test.js +866 -866
- package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
- package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
- package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
- package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
- package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
- package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
- package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
- package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
- package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
- package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
- package/src/core/__tests__/flowEndToEnd.test.js +565 -565
- package/src/core/__tests__/flowFieldMapping.test.js +188 -189
- package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
- package/src/core/__tests__/flowSavePayload.test.js +170 -169
- package/src/core/__tests__/flowTemplates.test.js +311 -311
- package/src/core/__tests__/flowVersionStore.test.js +123 -123
- package/src/core/__tests__/messageProcessor.test.js +669 -669
- package/src/core/__tests__/stateManager.test.js +0 -1
- package/src/core/agentPool.js +2474 -2475
- package/src/core/agentScheduler.js +1 -4
- package/src/core/contextManager.js +708 -708
- package/src/core/flowExecutor.js +1510 -1510
- package/src/core/flowFieldMapping.js +136 -138
- package/src/core/messageProcessor.js +953 -954
- package/src/core/orchestrator.js +593 -595
- package/src/core/stateManager.js +1765 -1752
- package/src/index.js +1221 -1221
- package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
- package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
- package/src/interfaces/__tests__/imageServing.test.js +228 -228
- package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
- package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
- package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
- package/src/interfaces/schedulerRoutes.js +50 -50
- package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
- package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
- package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
- package/src/interfaces/terminal/api/session.js +265 -266
- package/src/interfaces/terminal/api/websocket.js +496 -497
- package/src/interfaces/terminal/components/AgentCreator.js +691 -705
- package/src/interfaces/terminal/components/AgentEditor.js +676 -678
- package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
- package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
- package/src/interfaces/terminal/components/Header.js +28 -28
- package/src/interfaces/terminal/components/Layout.js +598 -603
- package/src/interfaces/terminal/components/MessageList.js +280 -281
- package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
- package/src/interfaces/terminal/components/StatusBar.js +2 -0
- package/src/interfaces/terminal/index.js +168 -168
- package/src/interfaces/terminal/state/useAgentControl.js +496 -496
- package/src/interfaces/terminal/state/useAgents.js +537 -537
- package/src/interfaces/terminal/state/useMessages.js +629 -630
- package/src/interfaces/terminal/state/useTools.js +554 -554
- package/src/interfaces/terminal/utils/debugLogger.js +44 -44
- package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
- package/src/interfaces/webServer.js +7578 -7579
- package/src/interfaces/webServer.js.bak +7046 -7046
- package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
- package/src/modules/fileExplorer/controller.js +470 -469
- package/src/modules/fileExplorer/routes.js +285 -286
- package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
- package/src/modules/widget/__tests__/routes.test.js +677 -678
- package/src/modules/widget/__tests__/runtime.test.js +401 -401
- package/src/modules/widget/__tests__/versioning.test.js +309 -309
- package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
- package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
- package/src/modules/widget/routes.js +435 -435
- package/src/modules/widget/runtime/bundle.js +640 -640
- package/src/modules/widget/runtime/webComponentBundle.js +470 -470
- package/src/modules/widget/schema.js +182 -181
- package/src/modules/widget/widgetTool.js +1389 -1389
- package/src/services/__tests__/agentActivityService.test.js +401 -402
- package/src/services/__tests__/benchmarkService.test.js +184 -184
- package/src/services/__tests__/contextInjectionService.test.js +246 -246
- package/src/services/__tests__/conversationQuery.test.js +721 -723
- package/src/services/__tests__/credentialVault.test.js +469 -469
- package/src/services/__tests__/discordService.integration.test.js +638 -639
- package/src/services/__tests__/flowContextService.test.js +590 -590
- package/src/services/__tests__/memoryService.test.js +1 -1
- package/src/services/__tests__/messageSource.test.js +380 -380
- package/src/services/__tests__/modelRouterNaming.test.js +111 -111
- package/src/services/__tests__/projectDetector.test.js +34 -34
- package/src/services/__tests__/promptService.test.js +242 -242
- package/src/services/__tests__/telegramService.test.js +941 -941
- package/src/services/__tests__/tokenCountingService.test.js +48 -48
- package/src/services/agentActivityService.js +419 -420
- package/src/services/aiService.js +2997 -3001
- package/src/services/apiKeyManager.js +359 -359
- package/src/services/benchmarkService.js +196 -196
- package/src/services/codebaseKnowledgeService.js +2 -2
- package/src/services/composioService.js +738 -738
- package/src/services/conversationCompactionService.js +1258 -1257
- package/src/services/credentialVault.js +685 -685
- package/src/services/discordService.js +792 -793
- package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
- package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
- package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
- package/src/services/embeddings/autoRecall.js +218 -219
- package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
- package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
- package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
- package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
- package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
- package/src/services/embeddings/indexers/composioIndexer.js +279 -279
- package/src/services/embeddings/providerInterface.js +206 -206
- package/src/services/embeddings/providers/localProvider.js +11 -7
- package/src/services/embeddings/providers/openaiProvider.js +101 -101
- package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
- package/src/services/errorHandler.js +809 -809
- package/src/services/flowContextService.js +586 -586
- package/src/services/grounding/MockAdapter.js +125 -125
- package/src/services/modelRouterService.js +26 -31
- package/src/services/modelsService.js +322 -322
- package/src/services/ollamaService.js +452 -452
- package/src/services/projectDetector.js +403 -404
- package/src/services/promptService.js +418 -418
- package/src/services/qualityInspector.js +795 -795
- package/src/services/scheduleService.js +726 -726
- package/src/services/serviceRegistry.js +386 -386
- package/src/services/telegrafBot.js +174 -174
- package/src/services/telegramService.js +1972 -1972
- package/src/services/visualEditorBridge.js +1033 -1033
- package/src/services/visualEditorServer.js +1769 -1774
- package/src/services/whatsappService.js +667 -668
- package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
- package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
- package/src/tools/__tests__/agentDelayTool.test.js +342 -342
- package/src/tools/__tests__/baseTool.test.js +3 -3
- package/src/tools/__tests__/codeMapTool.test.js +915 -915
- package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
- package/src/tools/__tests__/fileTreeTool.test.js +274 -274
- package/src/tools/__tests__/filesystemTool.test.js +815 -815
- package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
- package/src/tools/__tests__/imageTool.validator.test.js +194 -194
- package/src/tools/__tests__/jobDoneTool.test.js +580 -581
- package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
- package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
- package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
- package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
- package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
- package/src/tools/__tests__/memoryTool.test.js +1 -1
- package/src/tools/__tests__/seekTool.test.js +282 -282
- package/src/tools/__tests__/skillsTool.search.test.js +164 -164
- package/src/tools/__tests__/skillsTool.test.js +226 -226
- package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
- package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
- package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
- package/src/tools/__tests__/taskManagerTool.test.js +866 -866
- package/src/tools/__tests__/terminalTool.test.js +448 -448
- package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
- package/src/tools/__tests__/userPromptTool.test.js +297 -297
- package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
- package/src/tools/__tests__/webTool.e2e.test.js +609 -603
- package/src/tools/__tests__/webTool.unit.test.js +195 -195
- package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
- package/src/tools/agentCommunicationTool.js +8 -10
- package/src/tools/agentDelayTool.js +496 -497
- package/src/tools/asyncToolManager.js +602 -603
- package/src/tools/baseTool.js +12 -11
- package/src/tools/cloneDetectionTool.js +576 -581
- package/src/tools/codeMapTool.js +0 -6
- package/src/tools/composioTool.js +617 -617
- package/src/tools/dependencyResolverTool.js +1211 -1212
- package/src/tools/desktop/DesktopTool.js +629 -638
- package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
- package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
- package/src/tools/desktop/__tests__/osController.test.js +364 -364
- package/src/tools/desktop/osController.js +491 -491
- package/src/tools/docxTool.js +623 -623
- package/src/tools/excelTool.js +636 -636
- package/src/tools/fileContentReplaceTool.js +5 -7
- package/src/tools/fileSystemTool.js +12 -19
- package/src/tools/fileTreeTool.js +840 -840
- package/src/tools/foundryWebSearchTool.js +273 -273
- package/src/tools/helpTool.js +198 -198
- package/src/tools/imageTool.js +1397 -1397
- package/src/tools/importAnalyzerTool.js +1056 -1056
- package/src/tools/jobDoneTool.js +495 -495
- package/src/tools/memoryTool.js +1 -1
- package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
- package/src/tools/office/pres/archetypes/agenda.js +61 -61
- package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
- package/src/tools/office/pres/archetypes/bigStat.js +140 -142
- package/src/tools/office/pres/archetypes/closing.js +70 -70
- package/src/tools/office/pres/archetypes/hero.js +70 -70
- package/src/tools/office/pres/archetypes/productHero.js +93 -94
- package/src/tools/office/pres/archetypes/table.js +73 -74
- package/src/tools/office/pres/backgrounds/orb.js +66 -66
- package/src/tools/office/pres/components.js +422 -423
- package/src/tools/officeTool.js +441 -441
- package/src/tools/pdfTool.js +625 -627
- package/src/tools/platformControlTool.js +1081 -1081
- package/src/tools/seekTool.js +917 -918
- package/src/tools/skillsTool.js +1 -1
- package/src/tools/staticAnalysisTool.js +2143 -2146
- package/src/tools/taskManagerTool.js +3324 -3324
- package/src/tools/terminalTool.js +2615 -2618
- package/src/tools/videoTool.js +1303 -1303
- package/src/tools/visionTool.js +508 -508
- package/src/tools/visualEditorTool.js +1289 -1290
- package/src/tools/webTool.js +3368 -3368
- package/src/tools/whatsappTool.js +464 -464
- package/src/types/__tests__/agent.test.js +499 -499
- package/src/types/__tests__/contextReference.test.js +606 -606
- package/src/types/__tests__/conversation.test.js +555 -555
- package/src/types/__tests__/toolCommand.test.js +584 -584
- package/src/types/contextReference.js +974 -971
- package/src/types/conversation.js +729 -729
- package/src/types/toolCommand.js +746 -746
- package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
- package/src/utilities/__tests__/auditReport.test.js +328 -328
- package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
- package/src/utilities/__tests__/jsonRepair.test.js +103 -104
- package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
- package/src/utilities/__tests__/platformUtils.test.js +80 -87
- package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
- package/src/utilities/__tests__/toolConstants.test.js +92 -94
- package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
- package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
- package/src/utilities/attachmentValidator.js +284 -288
- package/src/utilities/authCache.js.backup-1779570472481 +121 -121
- package/src/utilities/browserStealth.js +631 -630
- package/src/utilities/configManager.js +616 -617
- package/src/utilities/directoryAccessManager.js +564 -565
- package/src/utilities/fileProcessor.js +308 -307
- package/src/utilities/humanBehavior.js +454 -453
- package/src/utilities/logger.js +479 -479
- package/src/utilities/structuredFileValidator.js +696 -699
- package/src/utilities/tagParser.js +5 -10
- package/src/utilities/userDataDir.js +308 -308
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
- package/node_modules/minipass/LICENSE +0 -15
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
package/src/tools/videoTool.js
CHANGED
|
@@ -1,1303 +1,1303 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file tools/videoTool.js
|
|
3
|
-
* @description Tool for generating videos using AI models (resolved dynamically from backend)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import { promises as fs } from 'fs';
|
|
9
|
-
import { BaseTool } from './baseTool.js';
|
|
10
|
-
import { getGalleryService } from '../services/galleryService.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Configuration constants for video generation.
|
|
14
|
-
*
|
|
15
|
-
* Per-model limits (allowed resolutions, allowed durations, whether
|
|
16
|
-
* variants are supported) are NOT hardcoded here — they live in the
|
|
17
|
-
* model catalog (`video_config` block on each video-generation row) and
|
|
18
|
-
* are enforced authoritatively by the backend route. The CLI sends the
|
|
19
|
-
* request and surfaces the backend's 400 verbatim if the inputs aren't
|
|
20
|
-
* compatible with the chosen model. This keeps the CLI provider-agnostic
|
|
21
|
-
* (Sora 1, Sora 2, future video models all just work) and avoids
|
|
22
|
-
* silently-stale validation tables here.
|
|
23
|
-
*
|
|
24
|
-
* The constants below are CLI-side controls only (queue/concurrency/
|
|
25
|
-
* polling/temp file lifetime/timeout). No model-specific values.
|
|
26
|
-
*/
|
|
27
|
-
const VIDEO_CONFIG = {
|
|
28
|
-
DEFAULT_MODEL: null, // resolved dynamically by aiService
|
|
29
|
-
DEFAULT_WIDTH: 1280, // sensible default; any video model in the
|
|
30
|
-
DEFAULT_HEIGHT: 720, // catalog with `1280x720` accepts these
|
|
31
|
-
DEFAULT_DURATION: 4, // Sora 2's smallest unit; safe across providers
|
|
32
|
-
DEFAULT_VARIANTS: 1, // single video per job (Sora 2; Sora 1 supported up to 4)
|
|
33
|
-
MAX_PROMPT_LENGTH: 4000,
|
|
34
|
-
// Hard CLI safety bounds — let the backend/catalog be authoritative on
|
|
35
|
-
// *which specific values* are allowed for the chosen model, but reject
|
|
36
|
-
// obviously-bogus inputs locally so the user gets immediate feedback.
|
|
37
|
-
ABSOLUTE_MIN_DURATION: 1,
|
|
38
|
-
ABSOLUTE_MAX_DURATION: 60,
|
|
39
|
-
ABSOLUTE_MAX_VARIANTS: 8,
|
|
40
|
-
// Queue + scheduler controls (CLI-side only).
|
|
41
|
-
MAX_CONCURRENT: 2,
|
|
42
|
-
QUEUE_LIMIT: 5,
|
|
43
|
-
POLL_INTERVAL: 5000, // ms between status checks
|
|
44
|
-
MAX_POLL_TIME: 600000, // 10 minutes max wait per job
|
|
45
|
-
TEMP_CLEANUP_MS: 86400000, // 24 h (Sora 2 expires content after 24 h anyway)
|
|
46
|
-
DOWNLOAD_TIMEOUT: 300000 // 5 minutes for video download
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* VideoTool - Generate videos using Sora 2 AI model
|
|
51
|
-
* Supports async job-based processing with polling
|
|
52
|
-
*/
|
|
53
|
-
export class VideoTool extends BaseTool {
|
|
54
|
-
constructor(config = {}, logger = null) {
|
|
55
|
-
super(config, logger);
|
|
56
|
-
|
|
57
|
-
// Override tool ID
|
|
58
|
-
this.id = 'video-gen';
|
|
59
|
-
|
|
60
|
-
// Job queue and tracking
|
|
61
|
-
this.queue = [];
|
|
62
|
-
this.activeJobs = new Map(); // Currently processing jobs (max 2)
|
|
63
|
-
this.completedJobs = new Map();
|
|
64
|
-
this.isProcessing = false;
|
|
65
|
-
|
|
66
|
-
// AIService will be injected later
|
|
67
|
-
this.aiService = null;
|
|
68
|
-
|
|
69
|
-
// AgentPool will be injected later (for saving to conversation history)
|
|
70
|
-
this.agentPool = null;
|
|
71
|
-
|
|
72
|
-
// Temp directory for videos
|
|
73
|
-
this.tempDir = path.join(os.tmpdir(), 'loxia-videos');
|
|
74
|
-
|
|
75
|
-
// Cleanup timers
|
|
76
|
-
this.cleanupTimers = new Map();
|
|
77
|
-
|
|
78
|
-
// Polling timers
|
|
79
|
-
this.pollTimers = new Map();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Snapshot of the job state for the web-UI `/api/videos/jobs` endpoint.
|
|
84
|
-
* Serialises the three job maps into a stable JSON shape that the
|
|
85
|
-
* Video Jobs page can render + poll on. Intentionally excludes the
|
|
86
|
-
* large `result` blobs for completed jobs to keep the list light —
|
|
87
|
-
* consumers that want the full result hit the file-serve URL instead.
|
|
88
|
-
*
|
|
89
|
-
* @returns {{
|
|
90
|
-
* queued: Array<JobSummary>,
|
|
91
|
-
* active: Array<JobSummary>,
|
|
92
|
-
* completed: Array<JobSummary>,
|
|
93
|
-
* limits: { concurrency: number, queue: number },
|
|
94
|
-
* }}
|
|
95
|
-
*/
|
|
96
|
-
getJobsSnapshot() {
|
|
97
|
-
const summarize = (j) => ({
|
|
98
|
-
jobId: j.jobId,
|
|
99
|
-
agentId: j.agentId,
|
|
100
|
-
sessionId: j.sessionId,
|
|
101
|
-
prompt: typeof j.prompt === 'string' ? j.prompt.slice(0, 300) : '',
|
|
102
|
-
model: j.model,
|
|
103
|
-
width: j.width,
|
|
104
|
-
height: j.height,
|
|
105
|
-
duration: j.duration,
|
|
106
|
-
variants: j.variants,
|
|
107
|
-
status: j.status || 'unknown',
|
|
108
|
-
soraJobId: j.soraJobId || null,
|
|
109
|
-
outputPath: j.outputPath || null,
|
|
110
|
-
createdAt: j.createdAt || null,
|
|
111
|
-
startedAt: j.startedAt || null,
|
|
112
|
-
completedAt: j.completedAt || null,
|
|
113
|
-
error: j.error || null,
|
|
114
|
-
// Best-effort URL to the final video file if known. The existing
|
|
115
|
-
// /api/videos/:sessionId/:filename serve route locates the file.
|
|
116
|
-
videoUrl: (j.status === 'completed' && j.outputPath && j.sessionId)
|
|
117
|
-
? `/api/videos/${encodeURIComponent(j.sessionId)}/${encodeURIComponent(
|
|
118
|
-
typeof j.outputPath === 'string' ? j.outputPath.split(/[\\/]/).pop() : ''
|
|
119
|
-
)}`
|
|
120
|
-
: null,
|
|
121
|
-
});
|
|
122
|
-
return {
|
|
123
|
-
queued: Array.from(this.queue).map(summarize),
|
|
124
|
-
active: Array.from(this.activeJobs.values()).map(summarize),
|
|
125
|
-
completed: Array.from(this.completedJobs.values()).map(summarize),
|
|
126
|
-
limits: {
|
|
127
|
-
// Was reading the non-existent VIDEO_CONFIG.CONCURRENCY_LIMIT and
|
|
128
|
-
// falling through to `activeJobs.size + queue.length`, which is
|
|
129
|
-
// 0 when idle and produced a misleading `"concurrency": 0` in the
|
|
130
|
-
// /api/videos/jobs snapshot (the real cap is MAX_CONCURRENT=2).
|
|
131
|
-
concurrency: VIDEO_CONFIG?.MAX_CONCURRENT ?? null,
|
|
132
|
-
queue: VIDEO_CONFIG?.QUEUE_LIMIT ?? null,
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Cancel a video generation job by id. Removes from queue if still
|
|
139
|
-
* pending, marks active jobs for cancellation (best-effort — an
|
|
140
|
-
* in-flight Sora API call will still complete, but we stop polling
|
|
141
|
-
* and stop persisting the result). Returns { success, state }.
|
|
142
|
-
*
|
|
143
|
-
* Completed and already-failed jobs are not cancellable.
|
|
144
|
-
*/
|
|
145
|
-
cancelJob(jobId) {
|
|
146
|
-
// Queued — just remove from the queue.
|
|
147
|
-
const qIdx = this.queue.findIndex(j => j.jobId === jobId);
|
|
148
|
-
if (qIdx !== -1) {
|
|
149
|
-
const [job] = this.queue.splice(qIdx, 1);
|
|
150
|
-
job.status = 'cancelled';
|
|
151
|
-
job.completedAt = new Date().toISOString();
|
|
152
|
-
this.completedJobs.set(job.jobId, job);
|
|
153
|
-
this.logger?.info('Video job cancelled (was queued)', { jobId });
|
|
154
|
-
return { success: true, state: 'queued' };
|
|
155
|
-
}
|
|
156
|
-
// Active — mark + stop polling.
|
|
157
|
-
if (this.activeJobs.has(jobId)) {
|
|
158
|
-
const job = this.activeJobs.get(jobId);
|
|
159
|
-
job.status = 'cancelled';
|
|
160
|
-
job.completedAt = new Date().toISOString();
|
|
161
|
-
const timer = this.pollTimers?.get(jobId);
|
|
162
|
-
if (timer) { clearTimeout(timer); this.pollTimers.delete(jobId); }
|
|
163
|
-
this.activeJobs.delete(jobId);
|
|
164
|
-
this.completedJobs.set(jobId, job);
|
|
165
|
-
this.logger?.info('Video job cancelled (was active)', { jobId });
|
|
166
|
-
return { success: true, state: 'active' };
|
|
167
|
-
}
|
|
168
|
-
// Completed / unknown.
|
|
169
|
-
return { success: false, error: 'Job not found or already terminal' };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Set AI service for video generation
|
|
174
|
-
* @param {AIService} aiService - AI service instance
|
|
175
|
-
*/
|
|
176
|
-
setAIService(aiService) {
|
|
177
|
-
this.aiService = aiService;
|
|
178
|
-
this.logger?.info('AI Service set for VideoTool');
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Set Agent Pool for saving results to conversation history
|
|
183
|
-
* @param {AgentPool} agentPool - AgentPool instance
|
|
184
|
-
*/
|
|
185
|
-
setAgentPool(agentPool) {
|
|
186
|
-
this.agentPool = agentPool;
|
|
187
|
-
this.logger?.info('AgentPool set for VideoTool');
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Get tool description for agent system prompt
|
|
192
|
-
* @returns {string} Formatted tool description
|
|
193
|
-
*/
|
|
194
|
-
getDescription() {
|
|
195
|
-
return `Tool: Video Generator — generate videos from text using the platform's video model.
|
|
196
|
-
|
|
197
|
-
**Purpose:** Generate short videos from text descriptions. Videos are saved to disk and made available in chat. The active video model is resolved automatically from the model catalog (currently Sora 2 on this deployment); model-specific limits like allowed resolutions and durations come from the catalog and are enforced by the backend.
|
|
198
|
-
|
|
199
|
-
**CRITICAL: Automatic Execution**
|
|
200
|
-
- ANY \`\`\`json block with "toolId": "video-gen" will be EXECUTED IMMEDIATELY.
|
|
201
|
-
- Just output the command when you want to generate a video.
|
|
202
|
-
- Video generation takes minutes — a job ID is returned immediately and the file lands when the job finishes.
|
|
203
|
-
|
|
204
|
-
**USAGE:**
|
|
205
|
-
\`\`\`json
|
|
206
|
-
{
|
|
207
|
-
"toolId": "video-gen",
|
|
208
|
-
"parameters": {
|
|
209
|
-
"prompt": "Detailed description of the video",
|
|
210
|
-
"outputPath": "videos/filename.mp4",
|
|
211
|
-
"width": 1280,
|
|
212
|
-
"height": 720,
|
|
213
|
-
"duration": 4
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
\`\`\`
|
|
217
|
-
|
|
218
|
-
**Parameters:**
|
|
219
|
-
- **prompt** (required): Detailed description of the video.
|
|
220
|
-
- **outputPath** (optional): Where to save the .mp4 (permanent). Omit for a temp file.
|
|
221
|
-
- **width** / **height** (optional): Resolution. The catalog defines allowed pairs per model; the backend rejects unsupported pairs with a clear 400 listing valid values.
|
|
222
|
-
- **duration** (optional): Duration in seconds. The catalog defines allowed values per model.
|
|
223
|
-
- **variants** (optional): Number of video variants. Some models (e.g. Sora 2) only return one video per job; the backend will reject \`variants > 1\` with a clear message naming the model's limit.
|
|
224
|
-
- **model** (optional): Override the auto-resolved model. Must be a video-generation model in the catalog.
|
|
225
|
-
|
|
226
|
-
**Current model limits (Sora 2):**
|
|
227
|
-
- Resolutions: \`720x1280\`, \`1280x720\`, \`1024x1792\`, \`1792x1024\` (16:9 / 9:16 only — no square or 480p)
|
|
228
|
-
- Durations: \`4\`, \`8\`, or \`12\` seconds (those exact values, no others)
|
|
229
|
-
- Variants: 1 per job — request multiple separately if you need more
|
|
230
|
-
|
|
231
|
-
These limits live in the catalog row, not in this tool. If the model rotates (e.g. Sora 3) or a new video model is added, the allowed values update automatically and you'll see them in the backend's 400 if you submit unsupported inputs.
|
|
232
|
-
|
|
233
|
-
**EXAMPLE:**
|
|
234
|
-
User: "create a video of a cat playing"
|
|
235
|
-
You output:
|
|
236
|
-
\`\`\`json
|
|
237
|
-
{
|
|
238
|
-
"toolId": "video-gen",
|
|
239
|
-
"parameters": {
|
|
240
|
-
"prompt": "A fluffy orange tabby cat with white paws crouches low on a sunlit hardwood floor, eyes locked on a red laser dot. The cat pounces forward, slides slightly on the polished wood, then quickly pivots to chase the dot as it darts away. Warm afternoon sunlight streams through sheer curtains, casting soft golden highlights on the cat's fur. Cozy living room with a beige sofa in the background.",
|
|
241
|
-
"outputPath": "videos/cat-playing.mp4",
|
|
242
|
-
"width": 1280,
|
|
243
|
-
"height": 720,
|
|
244
|
-
"duration": 4
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
\`\`\`
|
|
248
|
-
|
|
249
|
-
**Prompt Guidelines (IMPORTANT):**
|
|
250
|
-
Sora simulates a physical world — write prompts as narratives, not camera commands. Use the CAST method: describe the **Character** (appearance, clothing, posture), **Action** (broken into beats: "takes three steps, pauses, looks back"), **Setting** (time of day, weather, specific objects), and **Tone/Atmosphere** (lighting quality, color palette, mood). Be specific and sensory — replace "beautiful street" with "rain-slick Tokyo asphalt reflecting neon signs." Keep prompts under 120 words, focus on ONE action per clip, and use simple camera cues only if needed ("wide shot," "close-up"). Anchor lighting explicitly ("warm golden hour sunlight with soft shadows") and name colors (teal, amber, magenta) for palette consistency. For character consistency across clips, repeat the same distinctive details (clothing colors, accessories, features) in each prompt. Avoid real people, copyrighted characters, and sensitive content.
|
|
251
|
-
|
|
252
|
-
**Notes:**
|
|
253
|
-
- Videos take 1-3 minutes to generate (Sora 2 short clips).
|
|
254
|
-
- Max ${VIDEO_CONFIG.QUEUE_LIMIT} videos in queue, max ${VIDEO_CONFIG.MAX_CONCURRENT} concurrent jobs.
|
|
255
|
-
- Generated videos expire on the provider after 24 hours; if you need to keep them, set an \`outputPath\` so the local copy persists.`;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Parse video generation parameters
|
|
260
|
-
* @param {string|Object} content - Raw content or parsed object
|
|
261
|
-
* @returns {Object} Parsed parameters
|
|
262
|
-
*/
|
|
263
|
-
parseParameters(content) {
|
|
264
|
-
// Handle JSON format
|
|
265
|
-
if (typeof content === 'object' && content !== null) {
|
|
266
|
-
return this._parseJSONParams(content);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Handle string format
|
|
270
|
-
if (typeof content === 'string') {
|
|
271
|
-
const trimmed = content.trim();
|
|
272
|
-
|
|
273
|
-
// Try to parse as JSON first
|
|
274
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
275
|
-
try {
|
|
276
|
-
const parsed = JSON.parse(trimmed);
|
|
277
|
-
return this._parseJSONParams(parsed);
|
|
278
|
-
} catch
|
|
279
|
-
// Not valid JSON, fall through to XML parsing
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Parse as XML
|
|
284
|
-
return this._parseXMLParams(content);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
throw new Error('Invalid parameter format');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Parse JSON parameters
|
|
292
|
-
* @private
|
|
293
|
-
*/
|
|
294
|
-
_parseJSONParams(obj) {
|
|
295
|
-
// Handle parameters wrapper (when called via toolId/parameters structure)
|
|
296
|
-
if (obj.parameters) {
|
|
297
|
-
obj = obj.parameters;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check for batch mode
|
|
301
|
-
if (obj.batch && Array.isArray(obj.batch)) {
|
|
302
|
-
return {
|
|
303
|
-
batch: true,
|
|
304
|
-
videos: obj.batch.map(vid => this._parseVideoParams(vid))
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
batch: false,
|
|
310
|
-
videos: [this._parseVideoParams(obj)]
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Parse XML parameters
|
|
316
|
-
* @private
|
|
317
|
-
*/
|
|
318
|
-
_parseXMLParams(content) {
|
|
319
|
-
const params = { batch: false, videos: [] };
|
|
320
|
-
|
|
321
|
-
// Check for batch mode
|
|
322
|
-
const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
|
|
323
|
-
|
|
324
|
-
if (batchMatch) {
|
|
325
|
-
params.batch = true;
|
|
326
|
-
const batchContent = batchMatch[1];
|
|
327
|
-
|
|
328
|
-
// Extract individual <video> blocks
|
|
329
|
-
const videoRegex = /<video>([\s\S]*?)<\/video>/gi;
|
|
330
|
-
let match;
|
|
331
|
-
|
|
332
|
-
while ((match = videoRegex.exec(batchContent)) !== null) {
|
|
333
|
-
params.videos.push(this._parseXMLVideo(match[1]));
|
|
334
|
-
}
|
|
335
|
-
} else {
|
|
336
|
-
// Single video mode
|
|
337
|
-
params.videos.push(this._parseXMLVideo(content));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (params.videos.length === 0) {
|
|
341
|
-
throw new Error('No valid video parameters found');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return params;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Parse single video parameters from object
|
|
349
|
-
* @private
|
|
350
|
-
*/
|
|
351
|
-
_parseVideoParams(obj) {
|
|
352
|
-
const outputPath = obj.outputPath || obj['output-path'] || null;
|
|
353
|
-
|
|
354
|
-
return {
|
|
355
|
-
prompt: obj.prompt || '',
|
|
356
|
-
outputPath: outputPath,
|
|
357
|
-
saveToProject: outputPath !== null,
|
|
358
|
-
model: obj.model || VIDEO_CONFIG.DEFAULT_MODEL,
|
|
359
|
-
width: parseInt(obj.width) || VIDEO_CONFIG.DEFAULT_WIDTH,
|
|
360
|
-
height: parseInt(obj.height) || VIDEO_CONFIG.DEFAULT_HEIGHT,
|
|
361
|
-
duration: parseInt(obj.duration) || VIDEO_CONFIG.DEFAULT_DURATION,
|
|
362
|
-
variants: parseInt(obj.variants) || VIDEO_CONFIG.DEFAULT_VARIANTS
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Parse single video parameters from XML string
|
|
368
|
-
* @private
|
|
369
|
-
*/
|
|
370
|
-
_parseXMLVideo(xmlContent) {
|
|
371
|
-
const extractTag = (tag) => {
|
|
372
|
-
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
|
373
|
-
const match = regex.exec(xmlContent);
|
|
374
|
-
return match ? match[1].trim() : null;
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
const outputPath = extractTag('output-path') || null;
|
|
378
|
-
|
|
379
|
-
return {
|
|
380
|
-
prompt: extractTag('prompt') || '',
|
|
381
|
-
outputPath: outputPath,
|
|
382
|
-
saveToProject: outputPath !== null,
|
|
383
|
-
model: extractTag('model') || VIDEO_CONFIG.DEFAULT_MODEL,
|
|
384
|
-
width: parseInt(extractTag('width')) || VIDEO_CONFIG.DEFAULT_WIDTH,
|
|
385
|
-
height: parseInt(extractTag('height')) || VIDEO_CONFIG.DEFAULT_HEIGHT,
|
|
386
|
-
duration: parseInt(extractTag('duration')) || VIDEO_CONFIG.DEFAULT_DURATION,
|
|
387
|
-
variants: parseInt(extractTag('variants')) || VIDEO_CONFIG.DEFAULT_VARIANTS
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Execute video generation
|
|
393
|
-
* @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
|
|
394
|
-
* @param {Object} context - Execution context
|
|
395
|
-
* @returns {Promise<Object>} Execution result
|
|
396
|
-
*/
|
|
397
|
-
async execute(params, context = {}) {
|
|
398
|
-
try {
|
|
399
|
-
const { agentId, projectDir, directoryAccess, sessionId } = context;
|
|
400
|
-
|
|
401
|
-
// Auto-detect and parse inputs
|
|
402
|
-
if (typeof params === 'string') {
|
|
403
|
-
this.logger?.info('VideoTool: Auto-parsing string parameters');
|
|
404
|
-
params = this.parseParameters(params);
|
|
405
|
-
} else if (typeof params === 'object' && params !== null && !params.videos) {
|
|
406
|
-
this.logger?.info('VideoTool: Normalizing object parameters');
|
|
407
|
-
params = this.parseParameters(params);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Validate parameters
|
|
411
|
-
this._validateParameters(params);
|
|
412
|
-
|
|
413
|
-
// Queue videos
|
|
414
|
-
const jobIds = [];
|
|
415
|
-
|
|
416
|
-
for (const videoParams of params.videos) {
|
|
417
|
-
// Create job
|
|
418
|
-
const jobId = this._generateJobId();
|
|
419
|
-
|
|
420
|
-
const job = {
|
|
421
|
-
jobId,
|
|
422
|
-
agentId,
|
|
423
|
-
sessionId,
|
|
424
|
-
prompt: videoParams.prompt,
|
|
425
|
-
outputPath: videoParams.outputPath,
|
|
426
|
-
saveToProject: videoParams.saveToProject,
|
|
427
|
-
model: videoParams.model,
|
|
428
|
-
width: videoParams.width,
|
|
429
|
-
height: videoParams.height,
|
|
430
|
-
duration: videoParams.duration,
|
|
431
|
-
variants: videoParams.variants,
|
|
432
|
-
projectDir: projectDir || process.cwd(),
|
|
433
|
-
directoryAccess,
|
|
434
|
-
// Per-agent video-gen config (reaches the async processor
|
|
435
|
-
// through the job). Used to gate `saveToGallery` below.
|
|
436
|
-
toolConfig: context?.toolConfig || null,
|
|
437
|
-
status: 'queued',
|
|
438
|
-
soraJobId: null, // Will be set when submitted to Sora
|
|
439
|
-
createdAt: new Date().toISOString()
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
// Check queue limit
|
|
443
|
-
if (this.queue.length >= VIDEO_CONFIG.QUEUE_LIMIT) {
|
|
444
|
-
return {
|
|
445
|
-
success: false,
|
|
446
|
-
error: `Queue limit reached (${VIDEO_CONFIG.QUEUE_LIMIT} videos). Please wait for current jobs to complete.`,
|
|
447
|
-
queueLength: this.queue.length
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
this.queue.push(job);
|
|
452
|
-
jobIds.push(jobId);
|
|
453
|
-
|
|
454
|
-
this.logger?.info(`Video generation job queued: ${jobId}`, {
|
|
455
|
-
prompt: videoParams.prompt.substring(0, 50) + '...',
|
|
456
|
-
queuePosition: this.queue.length
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Start processing if not already running
|
|
461
|
-
if (!this.isProcessing) {
|
|
462
|
-
this._processQueue().catch(err => {
|
|
463
|
-
this.logger?.error('Queue processing error:', err);
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Return immediate response
|
|
468
|
-
return {
|
|
469
|
-
success: true,
|
|
470
|
-
jobIds,
|
|
471
|
-
queueLength: this.queue.length,
|
|
472
|
-
activeJobs: this.activeJobs.size,
|
|
473
|
-
message: params.batch
|
|
474
|
-
? `${jobIds.length} videos queued for generation`
|
|
475
|
-
: 'Video queued for generation',
|
|
476
|
-
estimatedWaitTime: this._estimateWaitTime()
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
} catch (error) {
|
|
480
|
-
this.logger?.error('Video generation error:', error);
|
|
481
|
-
return {
|
|
482
|
-
success: false,
|
|
483
|
-
error: error.message
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Sanity-check inputs before submitting to the backend. Per-model limits
|
|
490
|
-
* (which exact resolutions and durations a given video model accepts)
|
|
491
|
-
* are NOT enforced here — the catalog declares them and the backend
|
|
492
|
-
* route validates against the catalog row. This keeps the CLI
|
|
493
|
-
* provider-agnostic; if the inputs aren't compatible with the chosen
|
|
494
|
-
* model, the backend's 400 surfaces back through the queue's `error`
|
|
495
|
-
* field with a clear message naming the allowed values.
|
|
496
|
-
*
|
|
497
|
-
* Local checks below cover only "obviously wrong" inputs that no video
|
|
498
|
-
* provider would accept — empty prompts, non-positive dimensions, etc.
|
|
499
|
-
*
|
|
500
|
-
* @private
|
|
501
|
-
*/
|
|
502
|
-
_validateParameters(params) {
|
|
503
|
-
if (!params.videos || params.videos.length === 0) {
|
|
504
|
-
throw new Error('No videos specified');
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
for (const vid of params.videos) {
|
|
508
|
-
if (!vid.prompt || vid.prompt.trim().length === 0) {
|
|
509
|
-
throw new Error('Video prompt is required');
|
|
510
|
-
}
|
|
511
|
-
if (vid.prompt.length > VIDEO_CONFIG.MAX_PROMPT_LENGTH) {
|
|
512
|
-
throw new Error(`Prompt too long (max ${VIDEO_CONFIG.MAX_PROMPT_LENGTH} characters)`);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Width/height must be positive integers — backend + provider
|
|
516
|
-
// decide which exact pairs are valid for the chosen model.
|
|
517
|
-
if (vid.width != null && (!Number.isInteger(vid.width) || vid.width <= 0)) {
|
|
518
|
-
throw new Error(`Invalid width: ${vid.width}. Must be a positive integer.`);
|
|
519
|
-
}
|
|
520
|
-
if (vid.height != null && (!Number.isInteger(vid.height) || vid.height <= 0)) {
|
|
521
|
-
throw new Error(`Invalid height: ${vid.height}. Must be a positive integer.`);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Duration: only catch absurd values here; the catalog declares
|
|
525
|
-
// the per-model allowed list (e.g. Sora 2: {4, 8, 12} seconds).
|
|
526
|
-
if (vid.duration != null) {
|
|
527
|
-
if (typeof vid.duration !== 'number' || vid.duration < VIDEO_CONFIG.ABSOLUTE_MIN_DURATION || vid.duration > VIDEO_CONFIG.ABSOLUTE_MAX_DURATION) {
|
|
528
|
-
throw new Error(`Invalid duration: ${vid.duration}. Must be a number between ${VIDEO_CONFIG.ABSOLUTE_MIN_DURATION} and ${VIDEO_CONFIG.ABSOLUTE_MAX_DURATION} seconds. The chosen model may support a narrower set — see /llm/models.`);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Variants: same approach — sanity-only. Some models (e.g. Sora 2)
|
|
533
|
-
// only return a single video per job; the backend will 400 if the
|
|
534
|
-
// model doesn't support multiple variants.
|
|
535
|
-
if (vid.variants != null) {
|
|
536
|
-
if (!Number.isInteger(vid.variants) || vid.variants < 1 || vid.variants > VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS) {
|
|
537
|
-
throw new Error(`Invalid variants: ${vid.variants}. Must be an integer between 1 and ${VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS}. The chosen model may only support 1.`);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (vid.outputPath) {
|
|
542
|
-
const ext = path.extname(vid.outputPath).toLowerCase();
|
|
543
|
-
if (ext && ext !== '.mp4') {
|
|
544
|
-
throw new Error(`Invalid format: ${ext}. Only .mp4 is supported`);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Process the video generation queue
|
|
552
|
-
* @private
|
|
553
|
-
*/
|
|
554
|
-
async _processQueue() {
|
|
555
|
-
if (this.isProcessing) {
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
this.isProcessing = true;
|
|
560
|
-
|
|
561
|
-
while (this.queue.length > 0 || this.activeJobs.size > 0) {
|
|
562
|
-
// Start new jobs if under concurrent limit
|
|
563
|
-
while (this.queue.length > 0 && this.activeJobs.size < VIDEO_CONFIG.MAX_CONCURRENT) {
|
|
564
|
-
const job = this.queue.shift();
|
|
565
|
-
await this._startVideoJob(job);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Wait a bit before checking again
|
|
569
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
this.isProcessing = false;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Start a video generation job
|
|
577
|
-
* @private
|
|
578
|
-
*/
|
|
579
|
-
async _startVideoJob(job) {
|
|
580
|
-
this.logger?.info(`Starting video generation job: ${job.jobId}`);
|
|
581
|
-
|
|
582
|
-
try {
|
|
583
|
-
job.status = 'submitting';
|
|
584
|
-
|
|
585
|
-
// Check if AI service is available
|
|
586
|
-
if (!this.aiService) {
|
|
587
|
-
throw new Error('AI service not available. Video generation requires AI service.');
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Submit to Sora API
|
|
591
|
-
const options = {
|
|
592
|
-
width: job.width,
|
|
593
|
-
height: job.height,
|
|
594
|
-
duration: job.duration,
|
|
595
|
-
variants: job.variants,
|
|
596
|
-
sessionId: job.sessionId
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
const result = await this.aiService.generateVideo(job.prompt, options);
|
|
600
|
-
|
|
601
|
-
// Store Sora job ID
|
|
602
|
-
job.soraJobId = result.jobId;
|
|
603
|
-
job.status = 'processing';
|
|
604
|
-
job.submittedAt = new Date().toISOString();
|
|
605
|
-
|
|
606
|
-
// Add to active jobs
|
|
607
|
-
this.activeJobs.set(job.jobId, job);
|
|
608
|
-
|
|
609
|
-
this.logger?.info(`Video job submitted to Sora: ${job.soraJobId}`, {
|
|
610
|
-
localJobId: job.jobId
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// Broadcast status update
|
|
614
|
-
this._broadcastJobStatus(job, 'processing', 'Video generation started');
|
|
615
|
-
|
|
616
|
-
// Start polling for completion
|
|
617
|
-
this._pollJobStatus(job);
|
|
618
|
-
|
|
619
|
-
} catch (error) {
|
|
620
|
-
this.logger?.error(`Failed to start video job: ${job.jobId}`, error);
|
|
621
|
-
|
|
622
|
-
job.status = 'failed';
|
|
623
|
-
job.error = error.message;
|
|
624
|
-
job.completedAt = new Date().toISOString();
|
|
625
|
-
|
|
626
|
-
this.completedJobs.set(job.jobId, job);
|
|
627
|
-
|
|
628
|
-
// Broadcast error
|
|
629
|
-
this._broadcastJobStatus(job, 'failed', error.message);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Poll for job completion
|
|
635
|
-
* @private
|
|
636
|
-
*/
|
|
637
|
-
async _pollJobStatus(job) {
|
|
638
|
-
const startTime = Date.now();
|
|
639
|
-
|
|
640
|
-
const poll = async () => {
|
|
641
|
-
try {
|
|
642
|
-
// Check if we've exceeded max poll time
|
|
643
|
-
if (Date.now() - startTime > VIDEO_CONFIG.MAX_POLL_TIME) {
|
|
644
|
-
throw new Error('Video generation timeout - exceeded maximum wait time');
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Get status from Sora - pass sessionId for API key retrieval
|
|
648
|
-
const status = await this.aiService.getVideoJobStatus(job.soraJobId, {
|
|
649
|
-
sessionId: job.sessionId,
|
|
650
|
-
model: job.model
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
this.logger?.debug(`Video job status: ${status.status}`, {
|
|
654
|
-
jobId: job.jobId,
|
|
655
|
-
soraJobId: job.soraJobId,
|
|
656
|
-
sessionId: job.sessionId
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
if (status.status === 'succeeded' || status.status === 'completed') {
|
|
660
|
-
// Job completed successfully
|
|
661
|
-
await this._handleJobComplete(job, status);
|
|
662
|
-
} else if (status.status === 'failed' || status.status === 'cancelled') {
|
|
663
|
-
// Job failed
|
|
664
|
-
throw new Error(status.error || 'Video generation failed');
|
|
665
|
-
} else {
|
|
666
|
-
// Still processing - poll again
|
|
667
|
-
const timer = setTimeout(() => poll(), VIDEO_CONFIG.POLL_INTERVAL);
|
|
668
|
-
this.pollTimers.set(job.jobId, timer);
|
|
669
|
-
}
|
|
670
|
-
} catch (error) {
|
|
671
|
-
this.logger?.error(`Video job failed: ${job.jobId}`, error);
|
|
672
|
-
await this._handleJobFailed(job, error);
|
|
673
|
-
}
|
|
674
|
-
};
|
|
675
|
-
|
|
676
|
-
// Start polling
|
|
677
|
-
poll();
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* Handle successful job completion
|
|
682
|
-
* @private
|
|
683
|
-
*/
|
|
684
|
-
async _handleJobComplete(job, status) {
|
|
685
|
-
this.logger?.info(`Video generation completed: ${job.jobId}`);
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
// Clear poll timer
|
|
689
|
-
const timer = this.pollTimers.get(job.jobId);
|
|
690
|
-
if (timer) {
|
|
691
|
-
clearTimeout(timer);
|
|
692
|
-
this.pollTimers.delete(job.jobId);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Remove from active jobs
|
|
696
|
-
this.activeJobs.delete(job.jobId);
|
|
697
|
-
|
|
698
|
-
// Get generation ID from status - Sora returns generation ID, not direct URL
|
|
699
|
-
const generationId = status.generationId || status.generations?.[0]?.id;
|
|
700
|
-
|
|
701
|
-
if (!generationId) {
|
|
702
|
-
throw new Error('No generation ID received from Sora');
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Construct video download URL through our backend proxy
|
|
706
|
-
// The backend will authenticate with Sora and stream the video
|
|
707
|
-
const backendUrl = this.aiService?.baseUrl || process.env.LOXIA_BACKEND_URL || 'http://localhost:3001';
|
|
708
|
-
const videoUrl = `${backendUrl}/llm/video-content/${generationId}?model=${job.model || ''}`;
|
|
709
|
-
|
|
710
|
-
this.logger?.info(`Video content URL: ${videoUrl}`, { generationId });
|
|
711
|
-
|
|
712
|
-
// Resolve output path and download
|
|
713
|
-
const resolvedOutputPath = await this._resolveOutputPath(job);
|
|
714
|
-
|
|
715
|
-
let savedToDisk = false;
|
|
716
|
-
let downloadError = null;
|
|
717
|
-
|
|
718
|
-
try {
|
|
719
|
-
await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
720
|
-
await this._downloadVideo(videoUrl, resolvedOutputPath, job.sessionId);
|
|
721
|
-
savedToDisk = true;
|
|
722
|
-
|
|
723
|
-
// Schedule cleanup if temp file
|
|
724
|
-
if (!job.saveToProject) {
|
|
725
|
-
this._scheduleCleanup(resolvedOutputPath, job.jobId);
|
|
726
|
-
}
|
|
727
|
-
} catch (err) {
|
|
728
|
-
downloadError = err.message;
|
|
729
|
-
this.logger?.warn(`Failed to save video to disk: ${err.message}`);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
job.status = 'completed';
|
|
733
|
-
job.result = {
|
|
734
|
-
jobId: job.jobId,
|
|
735
|
-
soraJobId: job.soraJobId,
|
|
736
|
-
prompt: job.prompt,
|
|
737
|
-
outputPath: job.outputPath,
|
|
738
|
-
resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
|
|
739
|
-
temporaryUrl: videoUrl,
|
|
740
|
-
savedToDisk,
|
|
741
|
-
downloadError,
|
|
742
|
-
success: true,
|
|
743
|
-
model: job.model,
|
|
744
|
-
width: job.width,
|
|
745
|
-
height: job.height,
|
|
746
|
-
duration: job.duration,
|
|
747
|
-
generations: status.generations || []
|
|
748
|
-
};
|
|
749
|
-
job.completedAt = new Date().toISOString();
|
|
750
|
-
|
|
751
|
-
this.completedJobs.set(job.jobId, job);
|
|
752
|
-
|
|
753
|
-
// Durable gallery copy — non-blocking, non-fatal. Skipped when
|
|
754
|
-
// the agent has opted out via `toolConfig.video-gen.saveToGallery`.
|
|
755
|
-
if (savedToDisk && resolvedOutputPath && job.toolConfig?.saveToGallery !== false) {
|
|
756
|
-
try {
|
|
757
|
-
let agentName = null;
|
|
758
|
-
if (this.agentPool && job.agentId) {
|
|
759
|
-
try {
|
|
760
|
-
const a = await this.agentPool.getAgent(job.agentId);
|
|
761
|
-
agentName = a?.name || null;
|
|
762
|
-
} catch { /* non-fatal */ }
|
|
763
|
-
}
|
|
764
|
-
await getGalleryService(this.logger).saveVideo({
|
|
765
|
-
sourcePath: resolvedOutputPath,
|
|
766
|
-
metadata: {
|
|
767
|
-
prompt: job.prompt,
|
|
768
|
-
model: job.model,
|
|
769
|
-
width: job.width,
|
|
770
|
-
height: job.height,
|
|
771
|
-
duration: job.duration,
|
|
772
|
-
variants: job.variants,
|
|
773
|
-
agentId: job.agentId,
|
|
774
|
-
agentName,
|
|
775
|
-
sessionId: job.sessionId,
|
|
776
|
-
jobId: job.jobId,
|
|
777
|
-
soraJobId: job.soraJobId,
|
|
778
|
-
createdAt: job.completedAt,
|
|
779
|
-
},
|
|
780
|
-
});
|
|
781
|
-
} catch (galErr) {
|
|
782
|
-
this.logger?.warn?.('Gallery save failed (non-fatal)', {
|
|
783
|
-
jobId: job.jobId,
|
|
784
|
-
error: galErr.message,
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Broadcast success
|
|
790
|
-
this._broadcastJobResult(job, savedToDisk ? resolvedOutputPath : null, videoUrl, savedToDisk);
|
|
791
|
-
|
|
792
|
-
// Save to conversation history
|
|
793
|
-
await this._saveToConversationHistory(job, false);
|
|
794
|
-
|
|
795
|
-
} catch (error) {
|
|
796
|
-
this.logger?.error(`Error handling job completion: ${job.jobId}`, error);
|
|
797
|
-
await this._handleJobFailed(job, error);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Handle job failure
|
|
803
|
-
* @private
|
|
804
|
-
*/
|
|
805
|
-
async _handleJobFailed(job, error) {
|
|
806
|
-
// Clear poll timer
|
|
807
|
-
const timer = this.pollTimers.get(job.jobId);
|
|
808
|
-
if (timer) {
|
|
809
|
-
clearTimeout(timer);
|
|
810
|
-
this.pollTimers.delete(job.jobId);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Remove from active jobs
|
|
814
|
-
this.activeJobs.delete(job.jobId);
|
|
815
|
-
|
|
816
|
-
job.status = 'failed';
|
|
817
|
-
job.error = error.message;
|
|
818
|
-
job.completedAt = new Date().toISOString();
|
|
819
|
-
|
|
820
|
-
this.completedJobs.set(job.jobId, job);
|
|
821
|
-
|
|
822
|
-
// Broadcast error
|
|
823
|
-
this._broadcastJobStatus(job, 'failed', error.message);
|
|
824
|
-
|
|
825
|
-
// Save error to conversation history
|
|
826
|
-
await this._saveToConversationHistory(job, true);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/**
|
|
830
|
-
* Broadcast job status update
|
|
831
|
-
* @private
|
|
832
|
-
*/
|
|
833
|
-
_broadcastJobStatus(job, status, message) {
|
|
834
|
-
if (global.loxiaWebServer && job.sessionId) {
|
|
835
|
-
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
836
|
-
type: 'videoJobStatus',
|
|
837
|
-
agentId: job.agentId,
|
|
838
|
-
jobId: job.jobId,
|
|
839
|
-
soraJobId: job.soraJobId,
|
|
840
|
-
status,
|
|
841
|
-
message,
|
|
842
|
-
prompt: job.prompt,
|
|
843
|
-
timestamp: new Date().toISOString()
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Broadcast job result
|
|
850
|
-
* @private
|
|
851
|
-
*/
|
|
852
|
-
_broadcastJobResult(job, localPath, videoUrl, savedToDisk) {
|
|
853
|
-
this.logger?.info('📢 Broadcasting video result', {
|
|
854
|
-
jobId: job.jobId,
|
|
855
|
-
savedToDisk,
|
|
856
|
-
localPath,
|
|
857
|
-
originalVideoUrl: videoUrl,
|
|
858
|
-
hasWebServer: !!global.loxiaWebServer,
|
|
859
|
-
sessionId: job.sessionId
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
if (global.loxiaWebServer && job.sessionId) {
|
|
863
|
-
// Convert to web URL if saved locally
|
|
864
|
-
let webUrl = videoUrl;
|
|
865
|
-
if (savedToDisk && localPath) {
|
|
866
|
-
webUrl = this._convertToWebUrl(localPath, job.sessionId);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
870
|
-
type: 'videoGenerated',
|
|
871
|
-
agentId: job.agentId,
|
|
872
|
-
jobId: job.jobId,
|
|
873
|
-
soraJobId: job.soraJobId,
|
|
874
|
-
videoUrl: webUrl,
|
|
875
|
-
localPath,
|
|
876
|
-
prompt: job.prompt,
|
|
877
|
-
success: true,
|
|
878
|
-
savedToDisk,
|
|
879
|
-
isTemporary: !savedToDisk,
|
|
880
|
-
width: job.width,
|
|
881
|
-
height: job.height,
|
|
882
|
-
duration: job.duration,
|
|
883
|
-
timestamp: job.completedAt
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
this.logger?.info('Video generation broadcast sent', {
|
|
887
|
-
jobId: job.jobId,
|
|
888
|
-
savedToDisk
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/**
|
|
894
|
-
* Save result to conversation history
|
|
895
|
-
* @private
|
|
896
|
-
*/
|
|
897
|
-
async _saveToConversationHistory(job, isError) {
|
|
898
|
-
if (!this.agentPool || !job.agentId) {
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
try {
|
|
903
|
-
const agent = await this.agentPool.getAgent(job.agentId);
|
|
904
|
-
if (!agent) {
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
let message;
|
|
909
|
-
|
|
910
|
-
if (isError) {
|
|
911
|
-
message = {
|
|
912
|
-
id: `vid-error-${job.jobId}`,
|
|
913
|
-
role: 'system',
|
|
914
|
-
content: `Video generation failed: ${job.error}\n\n**Prompt:** ${job.prompt}`,
|
|
915
|
-
timestamp: job.completedAt,
|
|
916
|
-
type: 'error',
|
|
917
|
-
toolId: 'video-gen',
|
|
918
|
-
status: 'failed',
|
|
919
|
-
jobId: job.jobId
|
|
920
|
-
};
|
|
921
|
-
} else {
|
|
922
|
-
let content = `Video generated: ${job.prompt}`;
|
|
923
|
-
|
|
924
|
-
if (!job.result.savedToDisk) {
|
|
925
|
-
content += '\n\nWarning: Video is using a temporary URL (expires in ~24 hours).';
|
|
926
|
-
if (job.result.downloadError) {
|
|
927
|
-
content += `\n**Error:** ${job.result.downloadError}`;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
message = {
|
|
932
|
-
id: `vid-result-${job.jobId}`,
|
|
933
|
-
role: 'assistant',
|
|
934
|
-
content,
|
|
935
|
-
timestamp: job.completedAt,
|
|
936
|
-
// Use persistent URL (session-independent) for conversation history
|
|
937
|
-
// This allows videos to work after restart
|
|
938
|
-
videoUrl: job.result.savedToDisk
|
|
939
|
-
? this._convertToPersistentUrl(job.persistentFilename || path.basename(job.result.resolvedOutputPath))
|
|
940
|
-
: job.result.temporaryUrl,
|
|
941
|
-
// Also store filename for fallback lookups
|
|
942
|
-
videoFilename: job.persistentFilename || (job.result.resolvedOutputPath ? path.basename(job.result.resolvedOutputPath) : null),
|
|
943
|
-
type: 'video-result',
|
|
944
|
-
toolId: 'video-gen',
|
|
945
|
-
status: 'completed',
|
|
946
|
-
isTemporary: !job.result.savedToDisk,
|
|
947
|
-
savedToDisk: job.result.savedToDisk,
|
|
948
|
-
width: job.width,
|
|
949
|
-
height: job.height,
|
|
950
|
-
duration: job.duration
|
|
951
|
-
};
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// Add to full conversation
|
|
955
|
-
agent.conversations.full.messages.push(message);
|
|
956
|
-
agent.conversations.full.lastUpdated = job.completedAt;
|
|
957
|
-
|
|
958
|
-
// Add to current model conversation if exists
|
|
959
|
-
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
960
|
-
agent.conversations[agent.currentModel].messages.push(message);
|
|
961
|
-
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
agent.lastActivity = job.completedAt;
|
|
965
|
-
|
|
966
|
-
await this.agentPool.persistAgentState(job.agentId);
|
|
967
|
-
|
|
968
|
-
this.logger?.info('Video result saved to conversation history', {
|
|
969
|
-
agentId: job.agentId,
|
|
970
|
-
jobId: job.jobId,
|
|
971
|
-
isError
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
// Queue tool result so agent "sees" the completion/failure and can continue
|
|
975
|
-
if (isError) {
|
|
976
|
-
await this.agentPool.addToolResult(job.agentId, {
|
|
977
|
-
toolId: 'video-gen',
|
|
978
|
-
status: 'failed',
|
|
979
|
-
error: job.error,
|
|
980
|
-
result: {
|
|
981
|
-
jobId: job.jobId,
|
|
982
|
-
prompt: job.prompt
|
|
983
|
-
},
|
|
984
|
-
timestamp: job.completedAt
|
|
985
|
-
});
|
|
986
|
-
} else {
|
|
987
|
-
await this.agentPool.addToolResult(job.agentId, {
|
|
988
|
-
toolId: 'video-gen',
|
|
989
|
-
status: 'completed',
|
|
990
|
-
result: {
|
|
991
|
-
jobId: job.jobId,
|
|
992
|
-
prompt: job.prompt,
|
|
993
|
-
videoUrl: message.videoUrl,
|
|
994
|
-
localPath: job.result.resolvedOutputPath,
|
|
995
|
-
savedToDisk: job.result.savedToDisk,
|
|
996
|
-
isTemporary: !job.result.savedToDisk,
|
|
997
|
-
width: job.width,
|
|
998
|
-
height: job.height,
|
|
999
|
-
duration: job.duration
|
|
1000
|
-
},
|
|
1001
|
-
timestamp: job.completedAt
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
this.logger?.info('Video result queued for agent processing', {
|
|
1006
|
-
agentId: job.agentId,
|
|
1007
|
-
jobId: job.jobId,
|
|
1008
|
-
isError
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
} catch (error) {
|
|
1012
|
-
this.logger?.error('Failed to save video result to conversation history', {
|
|
1013
|
-
error: error.message,
|
|
1014
|
-
agentId: job.agentId,
|
|
1015
|
-
jobId: job.jobId
|
|
1016
|
-
});
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/**
|
|
1021
|
-
* Resolve output path - save to agent's working directory for persistence
|
|
1022
|
-
* @private
|
|
1023
|
-
*/
|
|
1024
|
-
async _resolveOutputPath(job) {
|
|
1025
|
-
// Use agent's working directory from directoryAccess settings
|
|
1026
|
-
// Fall back to projectDir, then cwd
|
|
1027
|
-
const workingDir = job.directoryAccess?.workingDirectory || job.projectDir || process.cwd();
|
|
1028
|
-
const videosDir = path.join(workingDir, 'generated-videos');
|
|
1029
|
-
|
|
1030
|
-
// Create videos directory
|
|
1031
|
-
await fs.mkdir(videosDir, { recursive: true });
|
|
1032
|
-
|
|
1033
|
-
let filename;
|
|
1034
|
-
if (job.outputPath) {
|
|
1035
|
-
// User specified a path - use it if within working directory
|
|
1036
|
-
if (path.isAbsolute(job.outputPath)) {
|
|
1037
|
-
// Absolute path - validate it's within working directory
|
|
1038
|
-
const normalizedPath = path.normalize(job.outputPath);
|
|
1039
|
-
if (!normalizedPath.startsWith(path.normalize(workingDir))) {
|
|
1040
|
-
throw new Error('Output path must be within agent working directory');
|
|
1041
|
-
}
|
|
1042
|
-
// Use the full path, create parent dirs
|
|
1043
|
-
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
|
1044
|
-
job.saveToProject = true;
|
|
1045
|
-
job.persistentFilename = path.basename(normalizedPath);
|
|
1046
|
-
return normalizedPath;
|
|
1047
|
-
} else {
|
|
1048
|
-
// Relative path - resolve relative to working directory
|
|
1049
|
-
const resolvedPath = path.normalize(path.join(workingDir, job.outputPath));
|
|
1050
|
-
if (!resolvedPath.startsWith(path.normalize(workingDir))) {
|
|
1051
|
-
throw new Error('Path traversal detected');
|
|
1052
|
-
}
|
|
1053
|
-
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
1054
|
-
job.saveToProject = true;
|
|
1055
|
-
job.persistentFilename = path.basename(resolvedPath);
|
|
1056
|
-
return resolvedPath;
|
|
1057
|
-
}
|
|
1058
|
-
} else {
|
|
1059
|
-
// Generate filename from job ID in generated-videos folder
|
|
1060
|
-
filename = `video-${job.jobId}.mp4`;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const resolvedPath = path.join(videosDir, filename);
|
|
1064
|
-
|
|
1065
|
-
this.logger?.info('📁 Video will be saved to agent directory', {
|
|
1066
|
-
workingDir,
|
|
1067
|
-
filename,
|
|
1068
|
-
resolvedPath
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
// Mark job as saved to project for correct URL generation
|
|
1072
|
-
job.saveToProject = true;
|
|
1073
|
-
job.persistentFilename = filename;
|
|
1074
|
-
|
|
1075
|
-
return resolvedPath;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
/**
|
|
1079
|
-
* Download video from URL
|
|
1080
|
-
* @private
|
|
1081
|
-
*/
|
|
1082
|
-
async _downloadVideo(videoUrl, outputPath, sessionId) {
|
|
1083
|
-
try {
|
|
1084
|
-
// Get API key for authentication with backend
|
|
1085
|
-
let apiKey = null;
|
|
1086
|
-
if (this.aiService?.apiKeyManager) {
|
|
1087
|
-
const keys = this.aiService.apiKeyManager.getKeysForRequest(null);
|
|
1088
|
-
apiKey = keys.loxiaApiKey;
|
|
1089
|
-
}
|
|
1090
|
-
if (!apiKey) {
|
|
1091
|
-
apiKey = process.env.LOXIA_API_KEY;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const headers = {};
|
|
1095
|
-
if (apiKey) {
|
|
1096
|
-
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
this.logger?.info('📥 Downloading video', {
|
|
1100
|
-
url: videoUrl,
|
|
1101
|
-
hasApiKey: !!apiKey,
|
|
1102
|
-
apiKeySource: apiKey ? (this.aiService?.apiKeyManager ? 'apiKeyManager' : 'env') : 'none',
|
|
1103
|
-
outputPath,
|
|
1104
|
-
sessionId
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1107
|
-
const response = await fetch(videoUrl, {
|
|
1108
|
-
headers,
|
|
1109
|
-
signal: AbortSignal.timeout(VIDEO_CONFIG.DOWNLOAD_TIMEOUT)
|
|
1110
|
-
});
|
|
1111
|
-
|
|
1112
|
-
if (!response.ok) {
|
|
1113
|
-
const errorText = await response.text().catch(() => '');
|
|
1114
|
-
throw new Error(`Failed to download video: HTTP ${response.status} - ${errorText}`);
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1118
|
-
await fs.writeFile(outputPath, buffer);
|
|
1119
|
-
|
|
1120
|
-
this.logger?.info(`Video saved to: ${outputPath}`);
|
|
1121
|
-
|
|
1122
|
-
} catch (error) {
|
|
1123
|
-
this.logger?.error('❌ Video download failed', {
|
|
1124
|
-
errorName: error.name,
|
|
1125
|
-
errorMessage: error.message,
|
|
1126
|
-
url: videoUrl,
|
|
1127
|
-
outputPath
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
if (error.name === 'TimeoutError') {
|
|
1131
|
-
throw new Error('Video download timeout');
|
|
1132
|
-
} else if (error.name === 'TypeError') {
|
|
1133
|
-
throw new Error(`Network error: ${error.message}
|
|
1134
|
-
} else {
|
|
1135
|
-
throw new Error(`Download failed: ${error.message}
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
/**
|
|
1141
|
-
* Schedule cleanup of temp file
|
|
1142
|
-
* @private
|
|
1143
|
-
*/
|
|
1144
|
-
_scheduleCleanup(filePath, jobId) {
|
|
1145
|
-
const timer = setTimeout(async () => {
|
|
1146
|
-
try {
|
|
1147
|
-
await fs.unlink(filePath);
|
|
1148
|
-
this.logger?.debug(`Cleaned up temp video: ${filePath}`);
|
|
1149
|
-
this.cleanupTimers.delete(jobId);
|
|
1150
|
-
} catch
|
|
1151
|
-
// File might already be deleted, ignore
|
|
1152
|
-
}
|
|
1153
|
-
}, VIDEO_CONFIG.TEMP_CLEANUP_MS);
|
|
1154
|
-
|
|
1155
|
-
this.cleanupTimers.set(jobId, timer);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
/**
|
|
1159
|
-
* Convert local file path to web-accessible URL
|
|
1160
|
-
* @private
|
|
1161
|
-
*/
|
|
1162
|
-
_convertToWebUrl(localPath, sessionId) {
|
|
1163
|
-
const filename = path.basename(localPath);
|
|
1164
|
-
const port = global.loxiaWebServer?.port || 8080;
|
|
1165
|
-
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1166
|
-
|
|
1167
|
-
if (host === '0.0.0.0') {
|
|
1168
|
-
host = 'localhost';
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const webUrl = `http://${host}:${port}/api/videos/${sessionId}/${filename}`;
|
|
1172
|
-
|
|
1173
|
-
this.logger?.info('🔗 Converting local path to web URL', {
|
|
1174
|
-
localPath,
|
|
1175
|
-
sessionId,
|
|
1176
|
-
webUrl
|
|
1177
|
-
});
|
|
1178
|
-
|
|
1179
|
-
return webUrl;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
/**
|
|
1183
|
-
* Convert filename to persistent (session-independent) URL
|
|
1184
|
-
* These URLs work after browser refresh and system restart
|
|
1185
|
-
* @private
|
|
1186
|
-
*/
|
|
1187
|
-
_convertToPersistentUrl(filename) {
|
|
1188
|
-
const port = global.loxiaWebServer?.port || 8080;
|
|
1189
|
-
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1190
|
-
|
|
1191
|
-
if (host === '0.0.0.0') {
|
|
1192
|
-
host = 'localhost';
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// Use the session-independent endpoint that searches all agent directories
|
|
1196
|
-
const persistentUrl = `http://${host}:${port}/api/generated-videos/${filename}`;
|
|
1197
|
-
|
|
1198
|
-
this.logger?.info('🔗 Generated persistent URL for video', {
|
|
1199
|
-
filename,
|
|
1200
|
-
persistentUrl
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
return persistentUrl;
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
/**
|
|
1207
|
-
* Estimate wait time based on queue
|
|
1208
|
-
* @private
|
|
1209
|
-
*/
|
|
1210
|
-
_estimateWaitTime() {
|
|
1211
|
-
const avgGenerationTime = 300; // 5 minutes in seconds
|
|
1212
|
-
const queuePosition = this.queue.length;
|
|
1213
|
-
const activeJobs = this.activeJobs.size;
|
|
1214
|
-
|
|
1215
|
-
if (queuePosition === 0 && activeJobs === 0) {
|
|
1216
|
-
return '~5 minutes';
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Calculate based on queue position and concurrent limit
|
|
1220
|
-
const waitingJobs = queuePosition + activeJobs;
|
|
1221
|
-
const batches = Math.ceil(waitingJobs / VIDEO_CONFIG.MAX_CONCURRENT);
|
|
1222
|
-
const estimatedSeconds = batches * avgGenerationTime;
|
|
1223
|
-
|
|
1224
|
-
const minutes = Math.floor(estimatedSeconds / 60);
|
|
1225
|
-
|
|
1226
|
-
if (minutes >= 60) {
|
|
1227
|
-
const hours = Math.floor(minutes / 60);
|
|
1228
|
-
const remainingMinutes = minutes % 60;
|
|
1229
|
-
return `~${hours}h ${remainingMinutes}m`;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
return `~${minutes} minutes`;
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
/**
|
|
1236
|
-
* Generate unique job ID
|
|
1237
|
-
* @private
|
|
1238
|
-
*/
|
|
1239
|
-
_generateJobId() {
|
|
1240
|
-
return `vid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* Get job status
|
|
1245
|
-
* @param {string} jobId - Job ID
|
|
1246
|
-
* @returns {Object} Job status
|
|
1247
|
-
*/
|
|
1248
|
-
getJobStatus(jobId) {
|
|
1249
|
-
// Check completed jobs
|
|
1250
|
-
if (this.completedJobs.has(jobId)) {
|
|
1251
|
-
return this.completedJobs.get(jobId);
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Check active jobs
|
|
1255
|
-
if (this.activeJobs.has(jobId)) {
|
|
1256
|
-
return this.activeJobs.get(jobId);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// Check queue
|
|
1260
|
-
const queuedJob = this.queue.find(job => job.jobId === jobId);
|
|
1261
|
-
if (queuedJob) {
|
|
1262
|
-
return queuedJob;
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
return {
|
|
1266
|
-
jobId,
|
|
1267
|
-
status: 'not_found'
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Cleanup on shutdown
|
|
1273
|
-
*/
|
|
1274
|
-
async cleanup() {
|
|
1275
|
-
this.logger?.info('Shutting down VideoTool');
|
|
1276
|
-
|
|
1277
|
-
// Clear all cleanup timers
|
|
1278
|
-
for (const timer of this.cleanupTimers.values()) {
|
|
1279
|
-
clearTimeout(timer);
|
|
1280
|
-
}
|
|
1281
|
-
this.cleanupTimers.clear();
|
|
1282
|
-
|
|
1283
|
-
// Clear all poll timers
|
|
1284
|
-
for (const timer of this.pollTimers.values()) {
|
|
1285
|
-
clearTimeout(timer);
|
|
1286
|
-
}
|
|
1287
|
-
this.pollTimers.clear();
|
|
1288
|
-
|
|
1289
|
-
// Mark queued jobs as cancelled
|
|
1290
|
-
for (const job of this.queue) {
|
|
1291
|
-
job.status = 'cancelled';
|
|
1292
|
-
}
|
|
1293
|
-
this.queue = [];
|
|
1294
|
-
|
|
1295
|
-
// Mark active jobs as cancelled
|
|
1296
|
-
for (const job of this.activeJobs.values()) {
|
|
1297
|
-
job.status = 'cancelled';
|
|
1298
|
-
}
|
|
1299
|
-
this.activeJobs.clear();
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
export default VideoTool;
|
|
1
|
+
/**
|
|
2
|
+
* @file tools/videoTool.js
|
|
3
|
+
* @description Tool for generating videos using AI models (resolved dynamically from backend)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import { BaseTool } from './baseTool.js';
|
|
10
|
+
import { getGalleryService } from '../services/galleryService.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration constants for video generation.
|
|
14
|
+
*
|
|
15
|
+
* Per-model limits (allowed resolutions, allowed durations, whether
|
|
16
|
+
* variants are supported) are NOT hardcoded here — they live in the
|
|
17
|
+
* model catalog (`video_config` block on each video-generation row) and
|
|
18
|
+
* are enforced authoritatively by the backend route. The CLI sends the
|
|
19
|
+
* request and surfaces the backend's 400 verbatim if the inputs aren't
|
|
20
|
+
* compatible with the chosen model. This keeps the CLI provider-agnostic
|
|
21
|
+
* (Sora 1, Sora 2, future video models all just work) and avoids
|
|
22
|
+
* silently-stale validation tables here.
|
|
23
|
+
*
|
|
24
|
+
* The constants below are CLI-side controls only (queue/concurrency/
|
|
25
|
+
* polling/temp file lifetime/timeout). No model-specific values.
|
|
26
|
+
*/
|
|
27
|
+
const VIDEO_CONFIG = {
|
|
28
|
+
DEFAULT_MODEL: null, // resolved dynamically by aiService
|
|
29
|
+
DEFAULT_WIDTH: 1280, // sensible default; any video model in the
|
|
30
|
+
DEFAULT_HEIGHT: 720, // catalog with `1280x720` accepts these
|
|
31
|
+
DEFAULT_DURATION: 4, // Sora 2's smallest unit; safe across providers
|
|
32
|
+
DEFAULT_VARIANTS: 1, // single video per job (Sora 2; Sora 1 supported up to 4)
|
|
33
|
+
MAX_PROMPT_LENGTH: 4000,
|
|
34
|
+
// Hard CLI safety bounds — let the backend/catalog be authoritative on
|
|
35
|
+
// *which specific values* are allowed for the chosen model, but reject
|
|
36
|
+
// obviously-bogus inputs locally so the user gets immediate feedback.
|
|
37
|
+
ABSOLUTE_MIN_DURATION: 1,
|
|
38
|
+
ABSOLUTE_MAX_DURATION: 60,
|
|
39
|
+
ABSOLUTE_MAX_VARIANTS: 8,
|
|
40
|
+
// Queue + scheduler controls (CLI-side only).
|
|
41
|
+
MAX_CONCURRENT: 2,
|
|
42
|
+
QUEUE_LIMIT: 5,
|
|
43
|
+
POLL_INTERVAL: 5000, // ms between status checks
|
|
44
|
+
MAX_POLL_TIME: 600000, // 10 minutes max wait per job
|
|
45
|
+
TEMP_CLEANUP_MS: 86400000, // 24 h (Sora 2 expires content after 24 h anyway)
|
|
46
|
+
DOWNLOAD_TIMEOUT: 300000 // 5 minutes for video download
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* VideoTool - Generate videos using Sora 2 AI model
|
|
51
|
+
* Supports async job-based processing with polling
|
|
52
|
+
*/
|
|
53
|
+
export class VideoTool extends BaseTool {
|
|
54
|
+
constructor(config = {}, logger = null) {
|
|
55
|
+
super(config, logger);
|
|
56
|
+
|
|
57
|
+
// Override tool ID
|
|
58
|
+
this.id = 'video-gen';
|
|
59
|
+
|
|
60
|
+
// Job queue and tracking
|
|
61
|
+
this.queue = [];
|
|
62
|
+
this.activeJobs = new Map(); // Currently processing jobs (max 2)
|
|
63
|
+
this.completedJobs = new Map();
|
|
64
|
+
this.isProcessing = false;
|
|
65
|
+
|
|
66
|
+
// AIService will be injected later
|
|
67
|
+
this.aiService = null;
|
|
68
|
+
|
|
69
|
+
// AgentPool will be injected later (for saving to conversation history)
|
|
70
|
+
this.agentPool = null;
|
|
71
|
+
|
|
72
|
+
// Temp directory for videos
|
|
73
|
+
this.tempDir = path.join(os.tmpdir(), 'loxia-videos');
|
|
74
|
+
|
|
75
|
+
// Cleanup timers
|
|
76
|
+
this.cleanupTimers = new Map();
|
|
77
|
+
|
|
78
|
+
// Polling timers
|
|
79
|
+
this.pollTimers = new Map();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Snapshot of the job state for the web-UI `/api/videos/jobs` endpoint.
|
|
84
|
+
* Serialises the three job maps into a stable JSON shape that the
|
|
85
|
+
* Video Jobs page can render + poll on. Intentionally excludes the
|
|
86
|
+
* large `result` blobs for completed jobs to keep the list light —
|
|
87
|
+
* consumers that want the full result hit the file-serve URL instead.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{
|
|
90
|
+
* queued: Array<JobSummary>,
|
|
91
|
+
* active: Array<JobSummary>,
|
|
92
|
+
* completed: Array<JobSummary>,
|
|
93
|
+
* limits: { concurrency: number, queue: number },
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
getJobsSnapshot() {
|
|
97
|
+
const summarize = (j) => ({
|
|
98
|
+
jobId: j.jobId,
|
|
99
|
+
agentId: j.agentId,
|
|
100
|
+
sessionId: j.sessionId,
|
|
101
|
+
prompt: typeof j.prompt === 'string' ? j.prompt.slice(0, 300) : '',
|
|
102
|
+
model: j.model,
|
|
103
|
+
width: j.width,
|
|
104
|
+
height: j.height,
|
|
105
|
+
duration: j.duration,
|
|
106
|
+
variants: j.variants,
|
|
107
|
+
status: j.status || 'unknown',
|
|
108
|
+
soraJobId: j.soraJobId || null,
|
|
109
|
+
outputPath: j.outputPath || null,
|
|
110
|
+
createdAt: j.createdAt || null,
|
|
111
|
+
startedAt: j.startedAt || null,
|
|
112
|
+
completedAt: j.completedAt || null,
|
|
113
|
+
error: j.error || null,
|
|
114
|
+
// Best-effort URL to the final video file if known. The existing
|
|
115
|
+
// /api/videos/:sessionId/:filename serve route locates the file.
|
|
116
|
+
videoUrl: (j.status === 'completed' && j.outputPath && j.sessionId)
|
|
117
|
+
? `/api/videos/${encodeURIComponent(j.sessionId)}/${encodeURIComponent(
|
|
118
|
+
typeof j.outputPath === 'string' ? j.outputPath.split(/[\\/]/).pop() : ''
|
|
119
|
+
)}`
|
|
120
|
+
: null,
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
queued: Array.from(this.queue).map(summarize),
|
|
124
|
+
active: Array.from(this.activeJobs.values()).map(summarize),
|
|
125
|
+
completed: Array.from(this.completedJobs.values()).map(summarize),
|
|
126
|
+
limits: {
|
|
127
|
+
// Was reading the non-existent VIDEO_CONFIG.CONCURRENCY_LIMIT and
|
|
128
|
+
// falling through to `activeJobs.size + queue.length`, which is
|
|
129
|
+
// 0 when idle and produced a misleading `"concurrency": 0` in the
|
|
130
|
+
// /api/videos/jobs snapshot (the real cap is MAX_CONCURRENT=2).
|
|
131
|
+
concurrency: VIDEO_CONFIG?.MAX_CONCURRENT ?? null,
|
|
132
|
+
queue: VIDEO_CONFIG?.QUEUE_LIMIT ?? null,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Cancel a video generation job by id. Removes from queue if still
|
|
139
|
+
* pending, marks active jobs for cancellation (best-effort — an
|
|
140
|
+
* in-flight Sora API call will still complete, but we stop polling
|
|
141
|
+
* and stop persisting the result). Returns { success, state }.
|
|
142
|
+
*
|
|
143
|
+
* Completed and already-failed jobs are not cancellable.
|
|
144
|
+
*/
|
|
145
|
+
cancelJob(jobId) {
|
|
146
|
+
// Queued — just remove from the queue.
|
|
147
|
+
const qIdx = this.queue.findIndex(j => j.jobId === jobId);
|
|
148
|
+
if (qIdx !== -1) {
|
|
149
|
+
const [job] = this.queue.splice(qIdx, 1);
|
|
150
|
+
job.status = 'cancelled';
|
|
151
|
+
job.completedAt = new Date().toISOString();
|
|
152
|
+
this.completedJobs.set(job.jobId, job);
|
|
153
|
+
this.logger?.info('Video job cancelled (was queued)', { jobId });
|
|
154
|
+
return { success: true, state: 'queued' };
|
|
155
|
+
}
|
|
156
|
+
// Active — mark + stop polling.
|
|
157
|
+
if (this.activeJobs.has(jobId)) {
|
|
158
|
+
const job = this.activeJobs.get(jobId);
|
|
159
|
+
job.status = 'cancelled';
|
|
160
|
+
job.completedAt = new Date().toISOString();
|
|
161
|
+
const timer = this.pollTimers?.get(jobId);
|
|
162
|
+
if (timer) { clearTimeout(timer); this.pollTimers.delete(jobId); }
|
|
163
|
+
this.activeJobs.delete(jobId);
|
|
164
|
+
this.completedJobs.set(jobId, job);
|
|
165
|
+
this.logger?.info('Video job cancelled (was active)', { jobId });
|
|
166
|
+
return { success: true, state: 'active' };
|
|
167
|
+
}
|
|
168
|
+
// Completed / unknown.
|
|
169
|
+
return { success: false, error: 'Job not found or already terminal' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Set AI service for video generation
|
|
174
|
+
* @param {AIService} aiService - AI service instance
|
|
175
|
+
*/
|
|
176
|
+
setAIService(aiService) {
|
|
177
|
+
this.aiService = aiService;
|
|
178
|
+
this.logger?.info('AI Service set for VideoTool');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Set Agent Pool for saving results to conversation history
|
|
183
|
+
* @param {AgentPool} agentPool - AgentPool instance
|
|
184
|
+
*/
|
|
185
|
+
setAgentPool(agentPool) {
|
|
186
|
+
this.agentPool = agentPool;
|
|
187
|
+
this.logger?.info('AgentPool set for VideoTool');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get tool description for agent system prompt
|
|
192
|
+
* @returns {string} Formatted tool description
|
|
193
|
+
*/
|
|
194
|
+
getDescription() {
|
|
195
|
+
return `Tool: Video Generator — generate videos from text using the platform's video model.
|
|
196
|
+
|
|
197
|
+
**Purpose:** Generate short videos from text descriptions. Videos are saved to disk and made available in chat. The active video model is resolved automatically from the model catalog (currently Sora 2 on this deployment); model-specific limits like allowed resolutions and durations come from the catalog and are enforced by the backend.
|
|
198
|
+
|
|
199
|
+
**CRITICAL: Automatic Execution**
|
|
200
|
+
- ANY \`\`\`json block with "toolId": "video-gen" will be EXECUTED IMMEDIATELY.
|
|
201
|
+
- Just output the command when you want to generate a video.
|
|
202
|
+
- Video generation takes minutes — a job ID is returned immediately and the file lands when the job finishes.
|
|
203
|
+
|
|
204
|
+
**USAGE:**
|
|
205
|
+
\`\`\`json
|
|
206
|
+
{
|
|
207
|
+
"toolId": "video-gen",
|
|
208
|
+
"parameters": {
|
|
209
|
+
"prompt": "Detailed description of the video",
|
|
210
|
+
"outputPath": "videos/filename.mp4",
|
|
211
|
+
"width": 1280,
|
|
212
|
+
"height": 720,
|
|
213
|
+
"duration": 4
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
\`\`\`
|
|
217
|
+
|
|
218
|
+
**Parameters:**
|
|
219
|
+
- **prompt** (required): Detailed description of the video.
|
|
220
|
+
- **outputPath** (optional): Where to save the .mp4 (permanent). Omit for a temp file.
|
|
221
|
+
- **width** / **height** (optional): Resolution. The catalog defines allowed pairs per model; the backend rejects unsupported pairs with a clear 400 listing valid values.
|
|
222
|
+
- **duration** (optional): Duration in seconds. The catalog defines allowed values per model.
|
|
223
|
+
- **variants** (optional): Number of video variants. Some models (e.g. Sora 2) only return one video per job; the backend will reject \`variants > 1\` with a clear message naming the model's limit.
|
|
224
|
+
- **model** (optional): Override the auto-resolved model. Must be a video-generation model in the catalog.
|
|
225
|
+
|
|
226
|
+
**Current model limits (Sora 2):**
|
|
227
|
+
- Resolutions: \`720x1280\`, \`1280x720\`, \`1024x1792\`, \`1792x1024\` (16:9 / 9:16 only — no square or 480p)
|
|
228
|
+
- Durations: \`4\`, \`8\`, or \`12\` seconds (those exact values, no others)
|
|
229
|
+
- Variants: 1 per job — request multiple separately if you need more
|
|
230
|
+
|
|
231
|
+
These limits live in the catalog row, not in this tool. If the model rotates (e.g. Sora 3) or a new video model is added, the allowed values update automatically and you'll see them in the backend's 400 if you submit unsupported inputs.
|
|
232
|
+
|
|
233
|
+
**EXAMPLE:**
|
|
234
|
+
User: "create a video of a cat playing"
|
|
235
|
+
You output:
|
|
236
|
+
\`\`\`json
|
|
237
|
+
{
|
|
238
|
+
"toolId": "video-gen",
|
|
239
|
+
"parameters": {
|
|
240
|
+
"prompt": "A fluffy orange tabby cat with white paws crouches low on a sunlit hardwood floor, eyes locked on a red laser dot. The cat pounces forward, slides slightly on the polished wood, then quickly pivots to chase the dot as it darts away. Warm afternoon sunlight streams through sheer curtains, casting soft golden highlights on the cat's fur. Cozy living room with a beige sofa in the background.",
|
|
241
|
+
"outputPath": "videos/cat-playing.mp4",
|
|
242
|
+
"width": 1280,
|
|
243
|
+
"height": 720,
|
|
244
|
+
"duration": 4
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
\`\`\`
|
|
248
|
+
|
|
249
|
+
**Prompt Guidelines (IMPORTANT):**
|
|
250
|
+
Sora simulates a physical world — write prompts as narratives, not camera commands. Use the CAST method: describe the **Character** (appearance, clothing, posture), **Action** (broken into beats: "takes three steps, pauses, looks back"), **Setting** (time of day, weather, specific objects), and **Tone/Atmosphere** (lighting quality, color palette, mood). Be specific and sensory — replace "beautiful street" with "rain-slick Tokyo asphalt reflecting neon signs." Keep prompts under 120 words, focus on ONE action per clip, and use simple camera cues only if needed ("wide shot," "close-up"). Anchor lighting explicitly ("warm golden hour sunlight with soft shadows") and name colors (teal, amber, magenta) for palette consistency. For character consistency across clips, repeat the same distinctive details (clothing colors, accessories, features) in each prompt. Avoid real people, copyrighted characters, and sensitive content.
|
|
251
|
+
|
|
252
|
+
**Notes:**
|
|
253
|
+
- Videos take 1-3 minutes to generate (Sora 2 short clips).
|
|
254
|
+
- Max ${VIDEO_CONFIG.QUEUE_LIMIT} videos in queue, max ${VIDEO_CONFIG.MAX_CONCURRENT} concurrent jobs.
|
|
255
|
+
- Generated videos expire on the provider after 24 hours; if you need to keep them, set an \`outputPath\` so the local copy persists.`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse video generation parameters
|
|
260
|
+
* @param {string|Object} content - Raw content or parsed object
|
|
261
|
+
* @returns {Object} Parsed parameters
|
|
262
|
+
*/
|
|
263
|
+
parseParameters(content) {
|
|
264
|
+
// Handle JSON format
|
|
265
|
+
if (typeof content === 'object' && content !== null) {
|
|
266
|
+
return this._parseJSONParams(content);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Handle string format
|
|
270
|
+
if (typeof content === 'string') {
|
|
271
|
+
const trimmed = content.trim();
|
|
272
|
+
|
|
273
|
+
// Try to parse as JSON first
|
|
274
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(trimmed);
|
|
277
|
+
return this._parseJSONParams(parsed);
|
|
278
|
+
} catch {
|
|
279
|
+
// Not valid JSON, fall through to XML parsing
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Parse as XML
|
|
284
|
+
return this._parseXMLParams(content);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new Error('Invalid parameter format');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parse JSON parameters
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
_parseJSONParams(obj) {
|
|
295
|
+
// Handle parameters wrapper (when called via toolId/parameters structure)
|
|
296
|
+
if (obj.parameters) {
|
|
297
|
+
obj = obj.parameters;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check for batch mode
|
|
301
|
+
if (obj.batch && Array.isArray(obj.batch)) {
|
|
302
|
+
return {
|
|
303
|
+
batch: true,
|
|
304
|
+
videos: obj.batch.map(vid => this._parseVideoParams(vid))
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
batch: false,
|
|
310
|
+
videos: [this._parseVideoParams(obj)]
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parse XML parameters
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
_parseXMLParams(content) {
|
|
319
|
+
const params = { batch: false, videos: [] };
|
|
320
|
+
|
|
321
|
+
// Check for batch mode
|
|
322
|
+
const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
|
|
323
|
+
|
|
324
|
+
if (batchMatch) {
|
|
325
|
+
params.batch = true;
|
|
326
|
+
const batchContent = batchMatch[1];
|
|
327
|
+
|
|
328
|
+
// Extract individual <video> blocks
|
|
329
|
+
const videoRegex = /<video>([\s\S]*?)<\/video>/gi;
|
|
330
|
+
let match;
|
|
331
|
+
|
|
332
|
+
while ((match = videoRegex.exec(batchContent)) !== null) {
|
|
333
|
+
params.videos.push(this._parseXMLVideo(match[1]));
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
// Single video mode
|
|
337
|
+
params.videos.push(this._parseXMLVideo(content));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (params.videos.length === 0) {
|
|
341
|
+
throw new Error('No valid video parameters found');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return params;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Parse single video parameters from object
|
|
349
|
+
* @private
|
|
350
|
+
*/
|
|
351
|
+
_parseVideoParams(obj) {
|
|
352
|
+
const outputPath = obj.outputPath || obj['output-path'] || null;
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
prompt: obj.prompt || '',
|
|
356
|
+
outputPath: outputPath,
|
|
357
|
+
saveToProject: outputPath !== null,
|
|
358
|
+
model: obj.model || VIDEO_CONFIG.DEFAULT_MODEL,
|
|
359
|
+
width: parseInt(obj.width) || VIDEO_CONFIG.DEFAULT_WIDTH,
|
|
360
|
+
height: parseInt(obj.height) || VIDEO_CONFIG.DEFAULT_HEIGHT,
|
|
361
|
+
duration: parseInt(obj.duration) || VIDEO_CONFIG.DEFAULT_DURATION,
|
|
362
|
+
variants: parseInt(obj.variants) || VIDEO_CONFIG.DEFAULT_VARIANTS
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Parse single video parameters from XML string
|
|
368
|
+
* @private
|
|
369
|
+
*/
|
|
370
|
+
_parseXMLVideo(xmlContent) {
|
|
371
|
+
const extractTag = (tag) => {
|
|
372
|
+
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
|
373
|
+
const match = regex.exec(xmlContent);
|
|
374
|
+
return match ? match[1].trim() : null;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const outputPath = extractTag('output-path') || null;
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
prompt: extractTag('prompt') || '',
|
|
381
|
+
outputPath: outputPath,
|
|
382
|
+
saveToProject: outputPath !== null,
|
|
383
|
+
model: extractTag('model') || VIDEO_CONFIG.DEFAULT_MODEL,
|
|
384
|
+
width: parseInt(extractTag('width')) || VIDEO_CONFIG.DEFAULT_WIDTH,
|
|
385
|
+
height: parseInt(extractTag('height')) || VIDEO_CONFIG.DEFAULT_HEIGHT,
|
|
386
|
+
duration: parseInt(extractTag('duration')) || VIDEO_CONFIG.DEFAULT_DURATION,
|
|
387
|
+
variants: parseInt(extractTag('variants')) || VIDEO_CONFIG.DEFAULT_VARIANTS
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Execute video generation
|
|
393
|
+
* @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
|
|
394
|
+
* @param {Object} context - Execution context
|
|
395
|
+
* @returns {Promise<Object>} Execution result
|
|
396
|
+
*/
|
|
397
|
+
async execute(params, context = {}) {
|
|
398
|
+
try {
|
|
399
|
+
const { agentId, projectDir, directoryAccess, sessionId } = context;
|
|
400
|
+
|
|
401
|
+
// Auto-detect and parse inputs
|
|
402
|
+
if (typeof params === 'string') {
|
|
403
|
+
this.logger?.info('VideoTool: Auto-parsing string parameters');
|
|
404
|
+
params = this.parseParameters(params);
|
|
405
|
+
} else if (typeof params === 'object' && params !== null && !params.videos) {
|
|
406
|
+
this.logger?.info('VideoTool: Normalizing object parameters');
|
|
407
|
+
params = this.parseParameters(params);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Validate parameters
|
|
411
|
+
this._validateParameters(params);
|
|
412
|
+
|
|
413
|
+
// Queue videos
|
|
414
|
+
const jobIds = [];
|
|
415
|
+
|
|
416
|
+
for (const videoParams of params.videos) {
|
|
417
|
+
// Create job
|
|
418
|
+
const jobId = this._generateJobId();
|
|
419
|
+
|
|
420
|
+
const job = {
|
|
421
|
+
jobId,
|
|
422
|
+
agentId,
|
|
423
|
+
sessionId,
|
|
424
|
+
prompt: videoParams.prompt,
|
|
425
|
+
outputPath: videoParams.outputPath,
|
|
426
|
+
saveToProject: videoParams.saveToProject,
|
|
427
|
+
model: videoParams.model,
|
|
428
|
+
width: videoParams.width,
|
|
429
|
+
height: videoParams.height,
|
|
430
|
+
duration: videoParams.duration,
|
|
431
|
+
variants: videoParams.variants,
|
|
432
|
+
projectDir: projectDir || process.cwd(),
|
|
433
|
+
directoryAccess,
|
|
434
|
+
// Per-agent video-gen config (reaches the async processor
|
|
435
|
+
// through the job). Used to gate `saveToGallery` below.
|
|
436
|
+
toolConfig: context?.toolConfig || null,
|
|
437
|
+
status: 'queued',
|
|
438
|
+
soraJobId: null, // Will be set when submitted to Sora
|
|
439
|
+
createdAt: new Date().toISOString()
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Check queue limit
|
|
443
|
+
if (this.queue.length >= VIDEO_CONFIG.QUEUE_LIMIT) {
|
|
444
|
+
return {
|
|
445
|
+
success: false,
|
|
446
|
+
error: `Queue limit reached (${VIDEO_CONFIG.QUEUE_LIMIT} videos). Please wait for current jobs to complete.`,
|
|
447
|
+
queueLength: this.queue.length
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
this.queue.push(job);
|
|
452
|
+
jobIds.push(jobId);
|
|
453
|
+
|
|
454
|
+
this.logger?.info(`Video generation job queued: ${jobId}`, {
|
|
455
|
+
prompt: videoParams.prompt.substring(0, 50) + '...',
|
|
456
|
+
queuePosition: this.queue.length
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Start processing if not already running
|
|
461
|
+
if (!this.isProcessing) {
|
|
462
|
+
this._processQueue().catch(err => {
|
|
463
|
+
this.logger?.error('Queue processing error:', err);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Return immediate response
|
|
468
|
+
return {
|
|
469
|
+
success: true,
|
|
470
|
+
jobIds,
|
|
471
|
+
queueLength: this.queue.length,
|
|
472
|
+
activeJobs: this.activeJobs.size,
|
|
473
|
+
message: params.batch
|
|
474
|
+
? `${jobIds.length} videos queued for generation`
|
|
475
|
+
: 'Video queued for generation',
|
|
476
|
+
estimatedWaitTime: this._estimateWaitTime()
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
} catch (error) {
|
|
480
|
+
this.logger?.error('Video generation error:', error);
|
|
481
|
+
return {
|
|
482
|
+
success: false,
|
|
483
|
+
error: error.message
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Sanity-check inputs before submitting to the backend. Per-model limits
|
|
490
|
+
* (which exact resolutions and durations a given video model accepts)
|
|
491
|
+
* are NOT enforced here — the catalog declares them and the backend
|
|
492
|
+
* route validates against the catalog row. This keeps the CLI
|
|
493
|
+
* provider-agnostic; if the inputs aren't compatible with the chosen
|
|
494
|
+
* model, the backend's 400 surfaces back through the queue's `error`
|
|
495
|
+
* field with a clear message naming the allowed values.
|
|
496
|
+
*
|
|
497
|
+
* Local checks below cover only "obviously wrong" inputs that no video
|
|
498
|
+
* provider would accept — empty prompts, non-positive dimensions, etc.
|
|
499
|
+
*
|
|
500
|
+
* @private
|
|
501
|
+
*/
|
|
502
|
+
_validateParameters(params) {
|
|
503
|
+
if (!params.videos || params.videos.length === 0) {
|
|
504
|
+
throw new Error('No videos specified');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const vid of params.videos) {
|
|
508
|
+
if (!vid.prompt || vid.prompt.trim().length === 0) {
|
|
509
|
+
throw new Error('Video prompt is required');
|
|
510
|
+
}
|
|
511
|
+
if (vid.prompt.length > VIDEO_CONFIG.MAX_PROMPT_LENGTH) {
|
|
512
|
+
throw new Error(`Prompt too long (max ${VIDEO_CONFIG.MAX_PROMPT_LENGTH} characters)`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Width/height must be positive integers — backend + provider
|
|
516
|
+
// decide which exact pairs are valid for the chosen model.
|
|
517
|
+
if (vid.width != null && (!Number.isInteger(vid.width) || vid.width <= 0)) {
|
|
518
|
+
throw new Error(`Invalid width: ${vid.width}. Must be a positive integer.`);
|
|
519
|
+
}
|
|
520
|
+
if (vid.height != null && (!Number.isInteger(vid.height) || vid.height <= 0)) {
|
|
521
|
+
throw new Error(`Invalid height: ${vid.height}. Must be a positive integer.`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Duration: only catch absurd values here; the catalog declares
|
|
525
|
+
// the per-model allowed list (e.g. Sora 2: {4, 8, 12} seconds).
|
|
526
|
+
if (vid.duration != null) {
|
|
527
|
+
if (typeof vid.duration !== 'number' || vid.duration < VIDEO_CONFIG.ABSOLUTE_MIN_DURATION || vid.duration > VIDEO_CONFIG.ABSOLUTE_MAX_DURATION) {
|
|
528
|
+
throw new Error(`Invalid duration: ${vid.duration}. Must be a number between ${VIDEO_CONFIG.ABSOLUTE_MIN_DURATION} and ${VIDEO_CONFIG.ABSOLUTE_MAX_DURATION} seconds. The chosen model may support a narrower set — see /llm/models.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Variants: same approach — sanity-only. Some models (e.g. Sora 2)
|
|
533
|
+
// only return a single video per job; the backend will 400 if the
|
|
534
|
+
// model doesn't support multiple variants.
|
|
535
|
+
if (vid.variants != null) {
|
|
536
|
+
if (!Number.isInteger(vid.variants) || vid.variants < 1 || vid.variants > VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS) {
|
|
537
|
+
throw new Error(`Invalid variants: ${vid.variants}. Must be an integer between 1 and ${VIDEO_CONFIG.ABSOLUTE_MAX_VARIANTS}. The chosen model may only support 1.`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (vid.outputPath) {
|
|
542
|
+
const ext = path.extname(vid.outputPath).toLowerCase();
|
|
543
|
+
if (ext && ext !== '.mp4') {
|
|
544
|
+
throw new Error(`Invalid format: ${ext}. Only .mp4 is supported`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Process the video generation queue
|
|
552
|
+
* @private
|
|
553
|
+
*/
|
|
554
|
+
async _processQueue() {
|
|
555
|
+
if (this.isProcessing) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.isProcessing = true;
|
|
560
|
+
|
|
561
|
+
while (this.queue.length > 0 || this.activeJobs.size > 0) {
|
|
562
|
+
// Start new jobs if under concurrent limit
|
|
563
|
+
while (this.queue.length > 0 && this.activeJobs.size < VIDEO_CONFIG.MAX_CONCURRENT) {
|
|
564
|
+
const job = this.queue.shift();
|
|
565
|
+
await this._startVideoJob(job);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Wait a bit before checking again
|
|
569
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
this.isProcessing = false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Start a video generation job
|
|
577
|
+
* @private
|
|
578
|
+
*/
|
|
579
|
+
async _startVideoJob(job) {
|
|
580
|
+
this.logger?.info(`Starting video generation job: ${job.jobId}`);
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
job.status = 'submitting';
|
|
584
|
+
|
|
585
|
+
// Check if AI service is available
|
|
586
|
+
if (!this.aiService) {
|
|
587
|
+
throw new Error('AI service not available. Video generation requires AI service.');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Submit to Sora API
|
|
591
|
+
const options = {
|
|
592
|
+
width: job.width,
|
|
593
|
+
height: job.height,
|
|
594
|
+
duration: job.duration,
|
|
595
|
+
variants: job.variants,
|
|
596
|
+
sessionId: job.sessionId
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const result = await this.aiService.generateVideo(job.prompt, options);
|
|
600
|
+
|
|
601
|
+
// Store Sora job ID
|
|
602
|
+
job.soraJobId = result.jobId;
|
|
603
|
+
job.status = 'processing';
|
|
604
|
+
job.submittedAt = new Date().toISOString();
|
|
605
|
+
|
|
606
|
+
// Add to active jobs
|
|
607
|
+
this.activeJobs.set(job.jobId, job);
|
|
608
|
+
|
|
609
|
+
this.logger?.info(`Video job submitted to Sora: ${job.soraJobId}`, {
|
|
610
|
+
localJobId: job.jobId
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Broadcast status update
|
|
614
|
+
this._broadcastJobStatus(job, 'processing', 'Video generation started');
|
|
615
|
+
|
|
616
|
+
// Start polling for completion
|
|
617
|
+
this._pollJobStatus(job);
|
|
618
|
+
|
|
619
|
+
} catch (error) {
|
|
620
|
+
this.logger?.error(`Failed to start video job: ${job.jobId}`, error);
|
|
621
|
+
|
|
622
|
+
job.status = 'failed';
|
|
623
|
+
job.error = error.message;
|
|
624
|
+
job.completedAt = new Date().toISOString();
|
|
625
|
+
|
|
626
|
+
this.completedJobs.set(job.jobId, job);
|
|
627
|
+
|
|
628
|
+
// Broadcast error
|
|
629
|
+
this._broadcastJobStatus(job, 'failed', error.message);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Poll for job completion
|
|
635
|
+
* @private
|
|
636
|
+
*/
|
|
637
|
+
async _pollJobStatus(job) {
|
|
638
|
+
const startTime = Date.now();
|
|
639
|
+
|
|
640
|
+
const poll = async () => {
|
|
641
|
+
try {
|
|
642
|
+
// Check if we've exceeded max poll time
|
|
643
|
+
if (Date.now() - startTime > VIDEO_CONFIG.MAX_POLL_TIME) {
|
|
644
|
+
throw new Error('Video generation timeout - exceeded maximum wait time');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Get status from Sora - pass sessionId for API key retrieval
|
|
648
|
+
const status = await this.aiService.getVideoJobStatus(job.soraJobId, {
|
|
649
|
+
sessionId: job.sessionId,
|
|
650
|
+
model: job.model
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
this.logger?.debug(`Video job status: ${status.status}`, {
|
|
654
|
+
jobId: job.jobId,
|
|
655
|
+
soraJobId: job.soraJobId,
|
|
656
|
+
sessionId: job.sessionId
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
if (status.status === 'succeeded' || status.status === 'completed') {
|
|
660
|
+
// Job completed successfully
|
|
661
|
+
await this._handleJobComplete(job, status);
|
|
662
|
+
} else if (status.status === 'failed' || status.status === 'cancelled') {
|
|
663
|
+
// Job failed
|
|
664
|
+
throw new Error(status.error || 'Video generation failed');
|
|
665
|
+
} else {
|
|
666
|
+
// Still processing - poll again
|
|
667
|
+
const timer = setTimeout(() => poll(), VIDEO_CONFIG.POLL_INTERVAL);
|
|
668
|
+
this.pollTimers.set(job.jobId, timer);
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
this.logger?.error(`Video job failed: ${job.jobId}`, error);
|
|
672
|
+
await this._handleJobFailed(job, error);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Start polling
|
|
677
|
+
poll();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Handle successful job completion
|
|
682
|
+
* @private
|
|
683
|
+
*/
|
|
684
|
+
async _handleJobComplete(job, status) {
|
|
685
|
+
this.logger?.info(`Video generation completed: ${job.jobId}`);
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
// Clear poll timer
|
|
689
|
+
const timer = this.pollTimers.get(job.jobId);
|
|
690
|
+
if (timer) {
|
|
691
|
+
clearTimeout(timer);
|
|
692
|
+
this.pollTimers.delete(job.jobId);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Remove from active jobs
|
|
696
|
+
this.activeJobs.delete(job.jobId);
|
|
697
|
+
|
|
698
|
+
// Get generation ID from status - Sora returns generation ID, not direct URL
|
|
699
|
+
const generationId = status.generationId || status.generations?.[0]?.id;
|
|
700
|
+
|
|
701
|
+
if (!generationId) {
|
|
702
|
+
throw new Error('No generation ID received from Sora');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Construct video download URL through our backend proxy
|
|
706
|
+
// The backend will authenticate with Sora and stream the video
|
|
707
|
+
const backendUrl = this.aiService?.baseUrl || process.env.LOXIA_BACKEND_URL || 'http://localhost:3001';
|
|
708
|
+
const videoUrl = `${backendUrl}/llm/video-content/${generationId}?model=${job.model || ''}`;
|
|
709
|
+
|
|
710
|
+
this.logger?.info(`Video content URL: ${videoUrl}`, { generationId });
|
|
711
|
+
|
|
712
|
+
// Resolve output path and download
|
|
713
|
+
const resolvedOutputPath = await this._resolveOutputPath(job);
|
|
714
|
+
|
|
715
|
+
let savedToDisk = false;
|
|
716
|
+
let downloadError = null;
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
720
|
+
await this._downloadVideo(videoUrl, resolvedOutputPath, job.sessionId);
|
|
721
|
+
savedToDisk = true;
|
|
722
|
+
|
|
723
|
+
// Schedule cleanup if temp file
|
|
724
|
+
if (!job.saveToProject) {
|
|
725
|
+
this._scheduleCleanup(resolvedOutputPath, job.jobId);
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
downloadError = err.message;
|
|
729
|
+
this.logger?.warn(`Failed to save video to disk: ${err.message}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
job.status = 'completed';
|
|
733
|
+
job.result = {
|
|
734
|
+
jobId: job.jobId,
|
|
735
|
+
soraJobId: job.soraJobId,
|
|
736
|
+
prompt: job.prompt,
|
|
737
|
+
outputPath: job.outputPath,
|
|
738
|
+
resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
|
|
739
|
+
temporaryUrl: videoUrl,
|
|
740
|
+
savedToDisk,
|
|
741
|
+
downloadError,
|
|
742
|
+
success: true,
|
|
743
|
+
model: job.model,
|
|
744
|
+
width: job.width,
|
|
745
|
+
height: job.height,
|
|
746
|
+
duration: job.duration,
|
|
747
|
+
generations: status.generations || []
|
|
748
|
+
};
|
|
749
|
+
job.completedAt = new Date().toISOString();
|
|
750
|
+
|
|
751
|
+
this.completedJobs.set(job.jobId, job);
|
|
752
|
+
|
|
753
|
+
// Durable gallery copy — non-blocking, non-fatal. Skipped when
|
|
754
|
+
// the agent has opted out via `toolConfig.video-gen.saveToGallery`.
|
|
755
|
+
if (savedToDisk && resolvedOutputPath && job.toolConfig?.saveToGallery !== false) {
|
|
756
|
+
try {
|
|
757
|
+
let agentName = null;
|
|
758
|
+
if (this.agentPool && job.agentId) {
|
|
759
|
+
try {
|
|
760
|
+
const a = await this.agentPool.getAgent(job.agentId);
|
|
761
|
+
agentName = a?.name || null;
|
|
762
|
+
} catch { /* non-fatal */ }
|
|
763
|
+
}
|
|
764
|
+
await getGalleryService(this.logger).saveVideo({
|
|
765
|
+
sourcePath: resolvedOutputPath,
|
|
766
|
+
metadata: {
|
|
767
|
+
prompt: job.prompt,
|
|
768
|
+
model: job.model,
|
|
769
|
+
width: job.width,
|
|
770
|
+
height: job.height,
|
|
771
|
+
duration: job.duration,
|
|
772
|
+
variants: job.variants,
|
|
773
|
+
agentId: job.agentId,
|
|
774
|
+
agentName,
|
|
775
|
+
sessionId: job.sessionId,
|
|
776
|
+
jobId: job.jobId,
|
|
777
|
+
soraJobId: job.soraJobId,
|
|
778
|
+
createdAt: job.completedAt,
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
} catch (galErr) {
|
|
782
|
+
this.logger?.warn?.('Gallery save failed (non-fatal)', {
|
|
783
|
+
jobId: job.jobId,
|
|
784
|
+
error: galErr.message,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Broadcast success
|
|
790
|
+
this._broadcastJobResult(job, savedToDisk ? resolvedOutputPath : null, videoUrl, savedToDisk);
|
|
791
|
+
|
|
792
|
+
// Save to conversation history
|
|
793
|
+
await this._saveToConversationHistory(job, false);
|
|
794
|
+
|
|
795
|
+
} catch (error) {
|
|
796
|
+
this.logger?.error(`Error handling job completion: ${job.jobId}`, error);
|
|
797
|
+
await this._handleJobFailed(job, error);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Handle job failure
|
|
803
|
+
* @private
|
|
804
|
+
*/
|
|
805
|
+
async _handleJobFailed(job, error) {
|
|
806
|
+
// Clear poll timer
|
|
807
|
+
const timer = this.pollTimers.get(job.jobId);
|
|
808
|
+
if (timer) {
|
|
809
|
+
clearTimeout(timer);
|
|
810
|
+
this.pollTimers.delete(job.jobId);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Remove from active jobs
|
|
814
|
+
this.activeJobs.delete(job.jobId);
|
|
815
|
+
|
|
816
|
+
job.status = 'failed';
|
|
817
|
+
job.error = error.message;
|
|
818
|
+
job.completedAt = new Date().toISOString();
|
|
819
|
+
|
|
820
|
+
this.completedJobs.set(job.jobId, job);
|
|
821
|
+
|
|
822
|
+
// Broadcast error
|
|
823
|
+
this._broadcastJobStatus(job, 'failed', error.message);
|
|
824
|
+
|
|
825
|
+
// Save error to conversation history
|
|
826
|
+
await this._saveToConversationHistory(job, true);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Broadcast job status update
|
|
831
|
+
* @private
|
|
832
|
+
*/
|
|
833
|
+
_broadcastJobStatus(job, status, message) {
|
|
834
|
+
if (global.loxiaWebServer && job.sessionId) {
|
|
835
|
+
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
836
|
+
type: 'videoJobStatus',
|
|
837
|
+
agentId: job.agentId,
|
|
838
|
+
jobId: job.jobId,
|
|
839
|
+
soraJobId: job.soraJobId,
|
|
840
|
+
status,
|
|
841
|
+
message,
|
|
842
|
+
prompt: job.prompt,
|
|
843
|
+
timestamp: new Date().toISOString()
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Broadcast job result
|
|
850
|
+
* @private
|
|
851
|
+
*/
|
|
852
|
+
_broadcastJobResult(job, localPath, videoUrl, savedToDisk) {
|
|
853
|
+
this.logger?.info('📢 Broadcasting video result', {
|
|
854
|
+
jobId: job.jobId,
|
|
855
|
+
savedToDisk,
|
|
856
|
+
localPath,
|
|
857
|
+
originalVideoUrl: videoUrl,
|
|
858
|
+
hasWebServer: !!global.loxiaWebServer,
|
|
859
|
+
sessionId: job.sessionId
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
if (global.loxiaWebServer && job.sessionId) {
|
|
863
|
+
// Convert to web URL if saved locally
|
|
864
|
+
let webUrl = videoUrl;
|
|
865
|
+
if (savedToDisk && localPath) {
|
|
866
|
+
webUrl = this._convertToWebUrl(localPath, job.sessionId);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
870
|
+
type: 'videoGenerated',
|
|
871
|
+
agentId: job.agentId,
|
|
872
|
+
jobId: job.jobId,
|
|
873
|
+
soraJobId: job.soraJobId,
|
|
874
|
+
videoUrl: webUrl,
|
|
875
|
+
localPath,
|
|
876
|
+
prompt: job.prompt,
|
|
877
|
+
success: true,
|
|
878
|
+
savedToDisk,
|
|
879
|
+
isTemporary: !savedToDisk,
|
|
880
|
+
width: job.width,
|
|
881
|
+
height: job.height,
|
|
882
|
+
duration: job.duration,
|
|
883
|
+
timestamp: job.completedAt
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
this.logger?.info('Video generation broadcast sent', {
|
|
887
|
+
jobId: job.jobId,
|
|
888
|
+
savedToDisk
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Save result to conversation history
|
|
895
|
+
* @private
|
|
896
|
+
*/
|
|
897
|
+
async _saveToConversationHistory(job, isError) {
|
|
898
|
+
if (!this.agentPool || !job.agentId) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
const agent = await this.agentPool.getAgent(job.agentId);
|
|
904
|
+
if (!agent) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
let message;
|
|
909
|
+
|
|
910
|
+
if (isError) {
|
|
911
|
+
message = {
|
|
912
|
+
id: `vid-error-${job.jobId}`,
|
|
913
|
+
role: 'system',
|
|
914
|
+
content: `Video generation failed: ${job.error}\n\n**Prompt:** ${job.prompt}`,
|
|
915
|
+
timestamp: job.completedAt,
|
|
916
|
+
type: 'error',
|
|
917
|
+
toolId: 'video-gen',
|
|
918
|
+
status: 'failed',
|
|
919
|
+
jobId: job.jobId
|
|
920
|
+
};
|
|
921
|
+
} else {
|
|
922
|
+
let content = `Video generated: ${job.prompt}`;
|
|
923
|
+
|
|
924
|
+
if (!job.result.savedToDisk) {
|
|
925
|
+
content += '\n\nWarning: Video is using a temporary URL (expires in ~24 hours).';
|
|
926
|
+
if (job.result.downloadError) {
|
|
927
|
+
content += `\n**Error:** ${job.result.downloadError}`;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
message = {
|
|
932
|
+
id: `vid-result-${job.jobId}`,
|
|
933
|
+
role: 'assistant',
|
|
934
|
+
content,
|
|
935
|
+
timestamp: job.completedAt,
|
|
936
|
+
// Use persistent URL (session-independent) for conversation history
|
|
937
|
+
// This allows videos to work after restart
|
|
938
|
+
videoUrl: job.result.savedToDisk
|
|
939
|
+
? this._convertToPersistentUrl(job.persistentFilename || path.basename(job.result.resolvedOutputPath))
|
|
940
|
+
: job.result.temporaryUrl,
|
|
941
|
+
// Also store filename for fallback lookups
|
|
942
|
+
videoFilename: job.persistentFilename || (job.result.resolvedOutputPath ? path.basename(job.result.resolvedOutputPath) : null),
|
|
943
|
+
type: 'video-result',
|
|
944
|
+
toolId: 'video-gen',
|
|
945
|
+
status: 'completed',
|
|
946
|
+
isTemporary: !job.result.savedToDisk,
|
|
947
|
+
savedToDisk: job.result.savedToDisk,
|
|
948
|
+
width: job.width,
|
|
949
|
+
height: job.height,
|
|
950
|
+
duration: job.duration
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Add to full conversation
|
|
955
|
+
agent.conversations.full.messages.push(message);
|
|
956
|
+
agent.conversations.full.lastUpdated = job.completedAt;
|
|
957
|
+
|
|
958
|
+
// Add to current model conversation if exists
|
|
959
|
+
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
960
|
+
agent.conversations[agent.currentModel].messages.push(message);
|
|
961
|
+
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
agent.lastActivity = job.completedAt;
|
|
965
|
+
|
|
966
|
+
await this.agentPool.persistAgentState(job.agentId);
|
|
967
|
+
|
|
968
|
+
this.logger?.info('Video result saved to conversation history', {
|
|
969
|
+
agentId: job.agentId,
|
|
970
|
+
jobId: job.jobId,
|
|
971
|
+
isError
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// Queue tool result so agent "sees" the completion/failure and can continue
|
|
975
|
+
if (isError) {
|
|
976
|
+
await this.agentPool.addToolResult(job.agentId, {
|
|
977
|
+
toolId: 'video-gen',
|
|
978
|
+
status: 'failed',
|
|
979
|
+
error: job.error,
|
|
980
|
+
result: {
|
|
981
|
+
jobId: job.jobId,
|
|
982
|
+
prompt: job.prompt
|
|
983
|
+
},
|
|
984
|
+
timestamp: job.completedAt
|
|
985
|
+
});
|
|
986
|
+
} else {
|
|
987
|
+
await this.agentPool.addToolResult(job.agentId, {
|
|
988
|
+
toolId: 'video-gen',
|
|
989
|
+
status: 'completed',
|
|
990
|
+
result: {
|
|
991
|
+
jobId: job.jobId,
|
|
992
|
+
prompt: job.prompt,
|
|
993
|
+
videoUrl: message.videoUrl,
|
|
994
|
+
localPath: job.result.resolvedOutputPath,
|
|
995
|
+
savedToDisk: job.result.savedToDisk,
|
|
996
|
+
isTemporary: !job.result.savedToDisk,
|
|
997
|
+
width: job.width,
|
|
998
|
+
height: job.height,
|
|
999
|
+
duration: job.duration
|
|
1000
|
+
},
|
|
1001
|
+
timestamp: job.completedAt
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
this.logger?.info('Video result queued for agent processing', {
|
|
1006
|
+
agentId: job.agentId,
|
|
1007
|
+
jobId: job.jobId,
|
|
1008
|
+
isError
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
this.logger?.error('Failed to save video result to conversation history', {
|
|
1013
|
+
error: error.message,
|
|
1014
|
+
agentId: job.agentId,
|
|
1015
|
+
jobId: job.jobId
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Resolve output path - save to agent's working directory for persistence
|
|
1022
|
+
* @private
|
|
1023
|
+
*/
|
|
1024
|
+
async _resolveOutputPath(job) {
|
|
1025
|
+
// Use agent's working directory from directoryAccess settings
|
|
1026
|
+
// Fall back to projectDir, then cwd
|
|
1027
|
+
const workingDir = job.directoryAccess?.workingDirectory || job.projectDir || process.cwd();
|
|
1028
|
+
const videosDir = path.join(workingDir, 'generated-videos');
|
|
1029
|
+
|
|
1030
|
+
// Create videos directory
|
|
1031
|
+
await fs.mkdir(videosDir, { recursive: true });
|
|
1032
|
+
|
|
1033
|
+
let filename;
|
|
1034
|
+
if (job.outputPath) {
|
|
1035
|
+
// User specified a path - use it if within working directory
|
|
1036
|
+
if (path.isAbsolute(job.outputPath)) {
|
|
1037
|
+
// Absolute path - validate it's within working directory
|
|
1038
|
+
const normalizedPath = path.normalize(job.outputPath);
|
|
1039
|
+
if (!normalizedPath.startsWith(path.normalize(workingDir))) {
|
|
1040
|
+
throw new Error('Output path must be within agent working directory');
|
|
1041
|
+
}
|
|
1042
|
+
// Use the full path, create parent dirs
|
|
1043
|
+
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
|
1044
|
+
job.saveToProject = true;
|
|
1045
|
+
job.persistentFilename = path.basename(normalizedPath);
|
|
1046
|
+
return normalizedPath;
|
|
1047
|
+
} else {
|
|
1048
|
+
// Relative path - resolve relative to working directory
|
|
1049
|
+
const resolvedPath = path.normalize(path.join(workingDir, job.outputPath));
|
|
1050
|
+
if (!resolvedPath.startsWith(path.normalize(workingDir))) {
|
|
1051
|
+
throw new Error('Path traversal detected');
|
|
1052
|
+
}
|
|
1053
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
1054
|
+
job.saveToProject = true;
|
|
1055
|
+
job.persistentFilename = path.basename(resolvedPath);
|
|
1056
|
+
return resolvedPath;
|
|
1057
|
+
}
|
|
1058
|
+
} else {
|
|
1059
|
+
// Generate filename from job ID in generated-videos folder
|
|
1060
|
+
filename = `video-${job.jobId}.mp4`;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const resolvedPath = path.join(videosDir, filename);
|
|
1064
|
+
|
|
1065
|
+
this.logger?.info('📁 Video will be saved to agent directory', {
|
|
1066
|
+
workingDir,
|
|
1067
|
+
filename,
|
|
1068
|
+
resolvedPath
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// Mark job as saved to project for correct URL generation
|
|
1072
|
+
job.saveToProject = true;
|
|
1073
|
+
job.persistentFilename = filename;
|
|
1074
|
+
|
|
1075
|
+
return resolvedPath;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Download video from URL
|
|
1080
|
+
* @private
|
|
1081
|
+
*/
|
|
1082
|
+
async _downloadVideo(videoUrl, outputPath, sessionId) {
|
|
1083
|
+
try {
|
|
1084
|
+
// Get API key for authentication with backend
|
|
1085
|
+
let apiKey = null;
|
|
1086
|
+
if (this.aiService?.apiKeyManager) {
|
|
1087
|
+
const keys = this.aiService.apiKeyManager.getKeysForRequest(null);
|
|
1088
|
+
apiKey = keys.loxiaApiKey;
|
|
1089
|
+
}
|
|
1090
|
+
if (!apiKey) {
|
|
1091
|
+
apiKey = process.env.LOXIA_API_KEY;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const headers = {};
|
|
1095
|
+
if (apiKey) {
|
|
1096
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
this.logger?.info('📥 Downloading video', {
|
|
1100
|
+
url: videoUrl,
|
|
1101
|
+
hasApiKey: !!apiKey,
|
|
1102
|
+
apiKeySource: apiKey ? (this.aiService?.apiKeyManager ? 'apiKeyManager' : 'env') : 'none',
|
|
1103
|
+
outputPath,
|
|
1104
|
+
sessionId
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
const response = await fetch(videoUrl, {
|
|
1108
|
+
headers,
|
|
1109
|
+
signal: AbortSignal.timeout(VIDEO_CONFIG.DOWNLOAD_TIMEOUT)
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
if (!response.ok) {
|
|
1113
|
+
const errorText = await response.text().catch(() => '');
|
|
1114
|
+
throw new Error(`Failed to download video: HTTP ${response.status} - ${errorText}`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1118
|
+
await fs.writeFile(outputPath, buffer);
|
|
1119
|
+
|
|
1120
|
+
this.logger?.info(`Video saved to: ${outputPath}`);
|
|
1121
|
+
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
this.logger?.error('❌ Video download failed', {
|
|
1124
|
+
errorName: error.name,
|
|
1125
|
+
errorMessage: error.message,
|
|
1126
|
+
url: videoUrl,
|
|
1127
|
+
outputPath
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
if (error.name === 'TimeoutError') {
|
|
1131
|
+
throw new Error('Video download timeout', { cause: error });
|
|
1132
|
+
} else if (error.name === 'TypeError') {
|
|
1133
|
+
throw new Error(`Network error: ${error.message}`, { cause: error });
|
|
1134
|
+
} else {
|
|
1135
|
+
throw new Error(`Download failed: ${error.message}`, { cause: error });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Schedule cleanup of temp file
|
|
1142
|
+
* @private
|
|
1143
|
+
*/
|
|
1144
|
+
_scheduleCleanup(filePath, jobId) {
|
|
1145
|
+
const timer = setTimeout(async () => {
|
|
1146
|
+
try {
|
|
1147
|
+
await fs.unlink(filePath);
|
|
1148
|
+
this.logger?.debug(`Cleaned up temp video: ${filePath}`);
|
|
1149
|
+
this.cleanupTimers.delete(jobId);
|
|
1150
|
+
} catch {
|
|
1151
|
+
// File might already be deleted, ignore
|
|
1152
|
+
}
|
|
1153
|
+
}, VIDEO_CONFIG.TEMP_CLEANUP_MS);
|
|
1154
|
+
|
|
1155
|
+
this.cleanupTimers.set(jobId, timer);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Convert local file path to web-accessible URL
|
|
1160
|
+
* @private
|
|
1161
|
+
*/
|
|
1162
|
+
_convertToWebUrl(localPath, sessionId) {
|
|
1163
|
+
const filename = path.basename(localPath);
|
|
1164
|
+
const port = global.loxiaWebServer?.port || 8080;
|
|
1165
|
+
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1166
|
+
|
|
1167
|
+
if (host === '0.0.0.0') {
|
|
1168
|
+
host = 'localhost';
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const webUrl = `http://${host}:${port}/api/videos/${sessionId}/${filename}`;
|
|
1172
|
+
|
|
1173
|
+
this.logger?.info('🔗 Converting local path to web URL', {
|
|
1174
|
+
localPath,
|
|
1175
|
+
sessionId,
|
|
1176
|
+
webUrl
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
return webUrl;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Convert filename to persistent (session-independent) URL
|
|
1184
|
+
* These URLs work after browser refresh and system restart
|
|
1185
|
+
* @private
|
|
1186
|
+
*/
|
|
1187
|
+
_convertToPersistentUrl(filename) {
|
|
1188
|
+
const port = global.loxiaWebServer?.port || 8080;
|
|
1189
|
+
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1190
|
+
|
|
1191
|
+
if (host === '0.0.0.0') {
|
|
1192
|
+
host = 'localhost';
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Use the session-independent endpoint that searches all agent directories
|
|
1196
|
+
const persistentUrl = `http://${host}:${port}/api/generated-videos/${filename}`;
|
|
1197
|
+
|
|
1198
|
+
this.logger?.info('🔗 Generated persistent URL for video', {
|
|
1199
|
+
filename,
|
|
1200
|
+
persistentUrl
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
return persistentUrl;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Estimate wait time based on queue
|
|
1208
|
+
* @private
|
|
1209
|
+
*/
|
|
1210
|
+
_estimateWaitTime() {
|
|
1211
|
+
const avgGenerationTime = 300; // 5 minutes in seconds
|
|
1212
|
+
const queuePosition = this.queue.length;
|
|
1213
|
+
const activeJobs = this.activeJobs.size;
|
|
1214
|
+
|
|
1215
|
+
if (queuePosition === 0 && activeJobs === 0) {
|
|
1216
|
+
return '~5 minutes';
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Calculate based on queue position and concurrent limit
|
|
1220
|
+
const waitingJobs = queuePosition + activeJobs;
|
|
1221
|
+
const batches = Math.ceil(waitingJobs / VIDEO_CONFIG.MAX_CONCURRENT);
|
|
1222
|
+
const estimatedSeconds = batches * avgGenerationTime;
|
|
1223
|
+
|
|
1224
|
+
const minutes = Math.floor(estimatedSeconds / 60);
|
|
1225
|
+
|
|
1226
|
+
if (minutes >= 60) {
|
|
1227
|
+
const hours = Math.floor(minutes / 60);
|
|
1228
|
+
const remainingMinutes = minutes % 60;
|
|
1229
|
+
return `~${hours}h ${remainingMinutes}m`;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return `~${minutes} minutes`;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Generate unique job ID
|
|
1237
|
+
* @private
|
|
1238
|
+
*/
|
|
1239
|
+
_generateJobId() {
|
|
1240
|
+
return `vid-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Get job status
|
|
1245
|
+
* @param {string} jobId - Job ID
|
|
1246
|
+
* @returns {Object} Job status
|
|
1247
|
+
*/
|
|
1248
|
+
getJobStatus(jobId) {
|
|
1249
|
+
// Check completed jobs
|
|
1250
|
+
if (this.completedJobs.has(jobId)) {
|
|
1251
|
+
return this.completedJobs.get(jobId);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Check active jobs
|
|
1255
|
+
if (this.activeJobs.has(jobId)) {
|
|
1256
|
+
return this.activeJobs.get(jobId);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Check queue
|
|
1260
|
+
const queuedJob = this.queue.find(job => job.jobId === jobId);
|
|
1261
|
+
if (queuedJob) {
|
|
1262
|
+
return queuedJob;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
jobId,
|
|
1267
|
+
status: 'not_found'
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Cleanup on shutdown
|
|
1273
|
+
*/
|
|
1274
|
+
async cleanup() {
|
|
1275
|
+
this.logger?.info('Shutting down VideoTool');
|
|
1276
|
+
|
|
1277
|
+
// Clear all cleanup timers
|
|
1278
|
+
for (const timer of this.cleanupTimers.values()) {
|
|
1279
|
+
clearTimeout(timer);
|
|
1280
|
+
}
|
|
1281
|
+
this.cleanupTimers.clear();
|
|
1282
|
+
|
|
1283
|
+
// Clear all poll timers
|
|
1284
|
+
for (const timer of this.pollTimers.values()) {
|
|
1285
|
+
clearTimeout(timer);
|
|
1286
|
+
}
|
|
1287
|
+
this.pollTimers.clear();
|
|
1288
|
+
|
|
1289
|
+
// Mark queued jobs as cancelled
|
|
1290
|
+
for (const job of this.queue) {
|
|
1291
|
+
job.status = 'cancelled';
|
|
1292
|
+
}
|
|
1293
|
+
this.queue = [];
|
|
1294
|
+
|
|
1295
|
+
// Mark active jobs as cancelled
|
|
1296
|
+
for (const job of this.activeJobs.values()) {
|
|
1297
|
+
job.status = 'cancelled';
|
|
1298
|
+
}
|
|
1299
|
+
this.activeJobs.clear();
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
export default VideoTool;
|