diff --git a/.env.desktop-signing.example b/.env.desktop-signing.example new file mode 100644 index 0000000..5da624c --- /dev/null +++ b/.env.desktop-signing.example @@ -0,0 +1,21 @@ +# Copy this file to .env.desktop-signing.local and fill the secret values locally. +# Do not commit .env.desktop-signing.local. + +# Required for commercial macOS distribution outside the Mac App Store. +# This must be a "Developer ID Application: ..." certificate exported as .p12. +CSC_LINK=/absolute/path/to/DeveloperIDApplication.p12 +CSC_KEY_PASSWORD= + +# Recommended notarization method: App Store Connect API key. +# The key ID is the suffix in AuthKey_.p8. +APPLE_API_KEY=/absolute/path/to/AuthKey_KEYID.p8 +APPLE_API_KEY_ID=KEYID +APPLE_API_ISSUER= + +# Optional but useful for Apple ID notarization and diagnostics. +APPLE_TEAM_ID=TEAMID + +# zip is the safest macOS target on this build machine and is required by auto-update. +# Use "zip dmg" on a machine where hdiutil can create DMGs reliably. +TRUEGROWTH_MAC_TARGETS=zip +TRUEGROWTH_NOTARIZE=true diff --git a/.gitignore b/.gitignore index a134720..2b6470f 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,7 @@ testem.log .env .env.* !.env.example +!.env.desktop-signing.example # logs *.log diff --git a/README.md b/README.md index 21f641e..d26008e 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ - **画布工作区**:保留 OpenTu Drawnix 无限画布,承载 AI 任务、素材、Frame、工具窗口与知识库内容。 - **任务与素材管理**:通过任务队列、素材库、统一缓存和历史记录复用生成结果。 - **数字人与声音 Runtime**:以 AIGCPanel 的 `soundTts`、`soundClone`、`videoGen`、`asr` 契约承接数字人口播、音色克隆、长文本 TTS、字幕和视频换声。 -- **AI 智能剪辑**:vendor 进 FunClip,预留 ASR、文本选段、LLM 高光剪辑、SRT 输出和素材回写。 -- **本地 Runtime 桥接**:提供 TrueGrowth Local API Gateway,统一暴露 AIGCPanel、FunClip、工作流和模型状态。 +- **AI 智能剪辑**:以 VideoAgent 剪辑适配器承接素材理解、文稿式剪辑、成片计划、字幕与发布资产包,重模型能力由模型中心按需准备。 +- **本地 Runtime 桥接**:提供 TrueGrowth Local API Gateway,统一暴露 AIGCPanel、VideoAgent、工作流和模型状态。 - **工具箱与扩展**:支持内部 React 工具、iframe 工具、Skill/Agent 和插件化运行时。 - **PPT 与内容工作流**:支持 Frame 幻灯片、PPT 导出、Markdown/Mermaid 转换和多媒体编辑。 @@ -48,10 +48,10 @@ pnpm start pnpm start # 启动 Web 开发服务 pnpm web:dev # 在 127.0.0.1:7200 启动 Web Renderer pnpm runtime:dev # 启动 TrueGrowth 本地 API Gateway 占位服务 -pnpm funclip:dev # 启动 FunClip 本地 Gradio Runtime(需先安装 Python 依赖) +pnpm funclip:dev # 旧 FunClip 兼容调试命令,日常剪辑工作流不需要启动 pnpm electron:dev # 启动 Electron 桌面壳,加载 127.0.0.1:7200 pnpm desktop:dev # 启动桌面调试(Web + Electron,本地 API 由桌面壳内置) -pnpm desktop:dev:full # 启动完整调试(额外预启动 FunClip,资源占用更高) +pnpm desktop:dev:full # 兼容旧命令;当前等同 desktop:dev,不再预启动重型剪辑运行时 pnpm desktop:build:mac # 构建 macOS 桌面端安装包 pnpm desktop:build:win # 构建 Windows 桌面端安装包(建议在 Windows/CI 上执行) pnpm build:web # 构建 Web 应用 @@ -65,7 +65,7 @@ pnpm manual:build # 生成用户手册 ### Local API Gateway -`pnpm runtime:dev` 默认监听 `http://127.0.0.1:48177`,用于工作台探测本地能力、提交本地任务,并桥接 AIGCPanel / FunClip Runtime: +`pnpm runtime:dev` 默认监听 `http://127.0.0.1:48177`,用于工作台探测本地能力、提交本地任务,并桥接 AIGCPanel / VideoAgent Runtime: - `/local-api/health` - `/local-api/assets` @@ -76,16 +76,16 @@ pnpm manual:build # 生成用户手册 - `/local-api/runtime` - `/local-api/aigcpanel` - `POST /local-api/aigcpanel/tasks` -- `/local-api/funclip` -- `POST /local-api/funclip/tasks` -- `POST /local-api/runtime/funclip/start` -- `POST /local-api/runtime/funclip/stop` +- `/local-api/videoagent` +- `POST /local-api/videoagent/tasks` +- `POST /local-api/runtime/videoagent/start` +- `POST /local-api/runtime/videoagent/stop` - `/local-api/workflows` - `POST /local-api/workflows/run` AIGCPanel 桥接会优先读取 `AIGCPANEL_HTTP_PORT` + `AIGCPANEL_HTTP_TOKEN`,否则读取 AIGCPanel Electron 写入的 `cli-auth.json`。当请求携带真实 `model/version/function` 或 `workflowName/workflowData` 时,Local API 会代理到 AIGCPanel 的 `/api/model/*` 或 `/api/workflow/*`;未连接时保持工作台 mock 任务闭环可用。 -FunClip 可通过 `pnpm funclip:dev` 独立启动,也可由 `POST /local-api/runtime/funclip/start` 懒启动。默认 `pnpm desktop:dev` 不预启动 FunClip,避免桌面调试和本地生图同时占用过多 CPU、内存或 GPU;只有需要同时调试智能剪辑运行时时再使用 `pnpm desktop:dev:full`。可用 `TRUEGROWTH_FUNCLIP_PORT`、`TRUEGROWTH_FUNCLIP_LANG`、`TRUEGROWTH_FUNCLIP_URL` 覆盖默认配置。 +VideoAgent 剪辑适配器默认以轻量本地 API 流程提供基础剪辑计划与媒体处理能力;完整高级模型由模型中心按需准备。`pnpm desktop:dev` 与兼容命令 `pnpm desktop:dev:full` 都不会预启动旧 FunClip,避免桌面调试和本地生图同时占用过多 CPU、内存或 GPU。旧 `/local-api/funclip/*` 入口仅用于历史兼容,新任务应使用 `/local-api/videoagent/*`。 ## 部署 diff --git a/apps/electron/build/entitlements.mac.inherit.plist b/apps/electron/build/entitlements.mac.inherit.plist new file mode 100644 index 0000000..446fe17 --- /dev/null +++ b/apps/electron/build/entitlements.mac.inherit.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/apps/electron/build/entitlements.mac.plist b/apps/electron/build/entitlements.mac.plist new file mode 100644 index 0000000..fc119a0 --- /dev/null +++ b/apps/electron/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + diff --git a/apps/electron/main.mjs b/apps/electron/main.mjs index e56255e..2b0db44 100644 --- a/apps/electron/main.mjs +++ b/apps/electron/main.mjs @@ -7,14 +7,25 @@ import { shell, utilityProcess, } from 'electron'; +import electronUpdater from 'electron-updater'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +const { autoUpdater } = electronUpdater; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const brandName = 'TrueGrowth'; const rendererUrl = process.env.VITE_DEV_SERVER_URL; const localApiPort = Number(process.env.TRUEGROWTH_LOCAL_API_PORT || 48177); +const updateFeedUrl = + process.env.TRUEGROWTH_UPDATE_FEED_URL || + 'https://truegrowth.benchu.cloud/desktop/stable/'; +const updateCheckDelayMs = Number( + process.env.TRUEGROWTH_UPDATE_CHECK_DELAY_MS || 15000 +); +const updateCheckIntervalMs = Number( + process.env.TRUEGROWTH_UPDATE_CHECK_INTERVAL_MS || 4 * 60 * 60 * 1000 +); const userDataPath = process.env.TRUEGROWTH_ELECTRON_USER_DATA || path.join(app.getPath('appData'), brandName); @@ -28,6 +39,10 @@ const brandIconPath = resolveAppPath( let mainWindow = null; let localApi = null; +let updateCheckTimer = null; +let updateCheckInFlight = null; +let updateDownloadInFlight = null; +let updateState = null; function readAppVersion() { try { @@ -41,11 +56,282 @@ function readAppVersion() { } const appVersion = readAppVersion(); +updateState = createInitialUpdateState(); function resolveAppPath(...segments) { return path.join(appRoot, ...segments); } +function createInitialUpdateState() { + return { + appVersion: appVersion || app.getVersion(), + available: false, + autoDownload: false, + canCheck: true, + canDownload: false, + canInstall: false, + channelUrl: updateFeedUrl, + downloaded: false, + downloading: false, + error: '', + feedReady: false, + isPackaged: app.isPackaged, + lastCheckedAt: null, + notes: '', + percent: 0, + platform: process.platform, + status: 'idle', + updateVersion: null, + }; +} + +function normalizeReleaseNotes(releaseNotes) { + if (Array.isArray(releaseNotes)) { + return releaseNotes + .map((item) => { + if (typeof item === 'string') { + return item; + } + if (item && typeof item === 'object') { + return String(item.note || item.notes || item.version || ''); + } + return ''; + }) + .filter(Boolean) + .join('\n'); + } + if (typeof releaseNotes === 'string') { + return releaseNotes; + } + return ''; +} + +function updateUpdaterState(patch) { + updateState = { + ...updateState, + ...patch, + appVersion: appVersion || app.getVersion(), + channelUrl: updateFeedUrl, + isPackaged: app.isPackaged, + platform: process.platform, + }; + broadcastUpdaterState(); + return updateState; +} + +function broadcastUpdaterState() { + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send('truegrowth-update:state', updateState); + } + }); +} + +function configureAutoUpdater() { + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.allowDowngrade = false; + autoUpdater.allowPrerelease = false; + autoUpdater.logger = console; + autoUpdater.setFeedURL({ + provider: 'generic', + url: updateFeedUrl, + }); + updateUpdaterState({ + autoDownload: autoUpdater.autoDownload, + feedReady: true, + }); + + autoUpdater.on('checking-for-update', () => { + updateUpdaterState({ + canCheck: false, + error: '', + status: 'checking', + }); + }); + + autoUpdater.on('update-not-available', () => { + updateUpdaterState({ + available: false, + canCheck: true, + canDownload: false, + canInstall: false, + downloaded: false, + downloading: false, + error: '', + lastCheckedAt: Date.now(), + percent: 0, + status: 'not-available', + updateVersion: null, + }); + }); + + autoUpdater.on('update-available', (info) => { + updateUpdaterState({ + available: true, + canCheck: true, + canDownload: true, + canInstall: false, + downloaded: false, + downloading: false, + error: '', + lastCheckedAt: Date.now(), + notes: normalizeReleaseNotes(info?.releaseNotes), + percent: 0, + status: 'available', + updateVersion: info?.version || null, + }); + }); + + autoUpdater.on('download-progress', (progress) => { + updateUpdaterState({ + available: true, + canCheck: false, + canDownload: false, + canInstall: false, + downloaded: false, + downloading: true, + error: '', + percent: Math.max(0, Math.min(100, Number(progress?.percent || 0))), + status: 'downloading', + }); + }); + + autoUpdater.on('update-downloaded', (info) => { + updateDownloadInFlight = null; + updateUpdaterState({ + available: true, + canCheck: true, + canDownload: false, + canInstall: true, + downloaded: true, + downloading: false, + error: '', + notes: normalizeReleaseNotes(info?.releaseNotes) || updateState.notes, + percent: 100, + status: 'downloaded', + updateVersion: info?.version || updateState.updateVersion, + }); + }); + + autoUpdater.on('error', (error) => { + updateCheckInFlight = null; + updateDownloadInFlight = null; + updateUpdaterState({ + canCheck: true, + canDownload: Boolean(updateState.available && !updateState.downloaded), + downloading: false, + error: + error instanceof Error + ? error.message + : String(error || '更新检查失败'), + status: 'error', + }); + }); +} + +async function checkForDesktopUpdate({ manual = false } = {}) { + if (!app.isPackaged && !process.env.TRUEGROWTH_ALLOW_DEV_UPDATES) { + return updateUpdaterState({ + canCheck: true, + error: manual ? '开发模式下不会连接正式更新通道。' : '', + feedReady: true, + status: manual ? 'dev-disabled' : 'idle', + }); + } + if (updateCheckInFlight) { + return updateCheckInFlight; + } + updateCheckInFlight = autoUpdater + .checkForUpdates() + .then(() => updateState) + .finally(() => { + updateCheckInFlight = null; + }); + return updateCheckInFlight; +} + +async function downloadDesktopUpdate() { + if (!app.isPackaged && !process.env.TRUEGROWTH_ALLOW_DEV_UPDATES) { + return updateUpdaterState({ + error: '开发模式下不会下载正式更新包。', + status: 'dev-disabled', + }); + } + if (!updateState.available) { + return updateUpdaterState({ + error: '当前没有可下载的新版本。', + status: 'error', + }); + } + if (updateDownloadInFlight) { + return updateDownloadInFlight; + } + updateDownloadInFlight = autoUpdater + .downloadUpdate() + .then(() => updateState) + .finally(() => { + if (updateState.status !== 'downloaded') { + updateDownloadInFlight = null; + } + }); + updateUpdaterState({ + canCheck: false, + canDownload: false, + downloading: true, + error: '', + status: 'downloading', + }); + return updateDownloadInFlight; +} + +function installDesktopUpdate() { + if (!updateState.downloaded) { + return updateUpdaterState({ + error: '更新包尚未下载完成。', + status: 'error', + }); + } + updateUpdaterState({ + canCheck: false, + canDownload: false, + canInstall: false, + status: 'installing', + }); + autoUpdater.quitAndInstall(false, true); + return updateState; +} + +function openDesktopUpdateChannel() { + void shell.openExternal(updateFeedUrl); + return updateState; +} + +function installUpdateIpc() { + ipcMain.handle('truegrowth-update:get-state', () => updateState); + ipcMain.handle('truegrowth-update:check', () => + checkForDesktopUpdate({ manual: true }) + ); + ipcMain.handle('truegrowth-update:download', () => downloadDesktopUpdate()); + ipcMain.handle('truegrowth-update:install', () => installDesktopUpdate()); + ipcMain.handle('truegrowth-update:open-channel', () => + openDesktopUpdateChannel() + ); +} + +function scheduleDesktopUpdateChecks() { + if (updateCheckTimer) { + clearInterval(updateCheckTimer); + updateCheckTimer = null; + } + setTimeout(() => { + void checkForDesktopUpdate().catch(() => undefined); + }, Math.max(0, updateCheckDelayMs)); + updateCheckTimer = setInterval(() => { + void checkForDesktopUpdate().catch(() => undefined); + }, Math.max(60_000, updateCheckIntervalMs)); +} + function createDesktopLocalRendererEntry(indexHtmlPath) { const html = fs.readFileSync(indexHtmlPath, 'utf8'); const appAssetPattern = @@ -72,8 +358,8 @@ process.env.TRUEGROWTH_DESKTOP_RESOURCE_DIR = process.env.TRUEGROWTH_DESKTOP_RESOURCE_DIR || (app.isPackaged ? process.resourcesPath : appRoot); - const hasSingleInstanceLock = app.requestSingleInstanceLock({ - rendererUrl, +const hasSingleInstanceLock = app.requestSingleInstanceLock({ + rendererUrl, userDataPath, }); @@ -203,6 +489,20 @@ function installApplicationMenu() { ? [{ type: 'separator' }, { role: 'front' }] : [{ role: 'close' }]), ]; + const helpMenu = [ + { + label: 'Check for Updates', + click: () => { + void checkForDesktopUpdate({ manual: true }); + }, + }, + { + label: 'Open Update Channel', + click: () => { + openDesktopUpdateChannel(); + }, + }, + ]; const template = [ ...(process.platform === 'darwin' ? [ @@ -231,7 +531,7 @@ function installApplicationMenu() { { label: 'Edit', submenu: editMenu }, { label: 'View', submenu: viewMenu }, { label: 'Window', submenu: windowMenu }, - { label: 'Help', submenu: [] }, + { label: 'Help', submenu: helpMenu }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); @@ -280,6 +580,7 @@ function createWindow() { mainWindow.webContents.once('did-finish-load', () => { sendWindowState(mainWindow); sendSystemThemeState(mainWindow); + broadcastUpdaterState(); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -327,9 +628,11 @@ async function startLocalApi() { } app.whenReady().then(async () => { + configureAutoUpdater(); installApplicationMenu(); installWindowControlsIpc(); installThemeIpc(); + installUpdateIpc(); if (process.platform === 'darwin' && app.dock) { app.dock.setIcon(brandIconPath); } @@ -341,6 +644,7 @@ app.whenReady().then(async () => { }); await startLocalApi(); createWindow(); + scheduleDesktopUpdateChecks(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -356,5 +660,9 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + if (updateCheckTimer) { + clearInterval(updateCheckTimer); + updateCheckTimer = null; + } localApi?.close(); }); diff --git a/apps/electron/preload.cjs b/apps/electron/preload.cjs index 77ef04c..1882fa9 100644 --- a/apps/electron/preload.cjs +++ b/apps/electron/preload.cjs @@ -35,6 +35,23 @@ const themeBridge = { }, }; +const updatesBridge = { + getState: () => ipcRenderer.invoke('truegrowth-update:get-state'), + check: () => ipcRenderer.invoke('truegrowth-update:check'), + download: () => ipcRenderer.invoke('truegrowth-update:download'), + install: () => ipcRenderer.invoke('truegrowth-update:install'), + openChannel: () => ipcRenderer.invoke('truegrowth-update:open-channel'), + onStateChange: (callback) => { + if (typeof callback !== 'function') { + return () => undefined; + } + const listener = (_event, state) => callback(state); + ipcRenderer.on('truegrowth-update:state', listener); + return () => + ipcRenderer.removeListener('truegrowth-update:state', listener); + }, +}; + contextBridge.exposeInMainWorld('trueGrowthRuntime', { app: 'TrueGrowth', platform: process.platform, @@ -42,6 +59,7 @@ contextBridge.exposeInMainWorld('trueGrowthRuntime', { localApiBaseUrl: 'http://127.0.0.1:48177/local-api', windowControls, theme: themeBridge, + updates: updatesBridge, }); contextBridge.exposeInMainWorld('__OPENTU_DESKTOP__', true); diff --git a/apps/web/public/version.json b/apps/web/public/version.json index 49c872b..6075f2f 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,6 +1,6 @@ { "version": "0.9.1", - "buildTime": "2026-07-06T20:03:33.383Z", + "buildTime": "2026-07-07T16:23:49.135Z", "gitCommit": "unknown", "changelog": [] } \ No newline at end of file diff --git a/apps/web/src/app/bootstrap.tsx b/apps/web/src/app/bootstrap.tsx index f03e935..63d34df 100644 --- a/apps/web/src/app/bootstrap.tsx +++ b/apps/web/src/app/bootstrap.tsx @@ -345,7 +345,10 @@ if (typeof window !== 'undefined') { }); } -if (hasServiceWorkerSupport && (isLocalDev || isServiceWorkerExplicitlyDisabled)) { +if ( + hasServiceWorkerSupport && + (isLocalDev || isServiceWorkerExplicitlyDisabled) +) { cleanupDisabledServiceWorker(); } @@ -667,7 +670,8 @@ if (shouldUseServiceWorker) { // 生成预览图 const thumbnailBlob = await generateVideoThumbnailFromBlob( videoBlob, - maxSize || 400 + maxSize || 400, + { allowMostlyBlackFallback: false } ); // 将 Blob 转换为 Data URL diff --git a/apps/web/src/workbench/AutoPublishingPage.spec.tsx b/apps/web/src/workbench/AutoPublishingPage.spec.tsx index ef2ffd1..285f7b9 100644 --- a/apps/web/src/workbench/AutoPublishingPage.spec.tsx +++ b/apps/web/src/workbench/AutoPublishingPage.spec.tsx @@ -9,6 +9,16 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +const { + mediaLibraryGridMock, + mediaLibraryInspectorMock, + mediaLibraryModalMock, +} = vi.hoisted(() => ({ + mediaLibraryGridMock: vi.fn(() => null), + mediaLibraryInspectorMock: vi.fn(() => null), + mediaLibraryModalMock: vi.fn(() => null), +})); + vi.mock('@drawnix/drawnix/runtime', () => ({ AssetProvider: ({ children }: { children: React.ReactNode }) => children, AudioPlaylistProvider: ({ children }: { children: React.ReactNode }) => @@ -35,6 +45,9 @@ vi.mock('../../../../packages/drawnix/src/utils/settings-manager', () => ({ DEFAULT_PROVIDER_IMAGE_API_COMPATIBILITY: 'openai-compatible', LEGACY_DEFAULT_PROVIDER_IMAGE_API_COMPATIBILITY: 'legacy', LEGACY_DEFAULT_PROVIDER_PROFILE_ID: 'legacy-default', + LOCAL_COMFYUI_DEFAULT_BASE_URL: 'http://127.0.0.1:8188', + LOCAL_COMFYUI_PROVIDER_NAME: '本地图片模型', + LOCAL_COMFYUI_PROVIDER_PROFILE_ID: 'local-comfyui', TUZI_DEFAULT_PROVIDER_NAME: 'TrueModel', TUZI_PROVIDER_DEFAULT_BASE_URL: 'https://api.example.test/v1', createModelRef: (profileId: unknown, modelId?: unknown) => ({ @@ -71,6 +84,12 @@ vi.mock('../../../../packages/drawnix/src/utils/settings-manager', () => ({ addListener: vi.fn(), removeListener: vi.fn(), }, + settingsManager: { + get: vi.fn(), + set: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + }, resolveInvocationRoute: () => ({ profileId: 'text-profile', modelId: 'qwen-text', @@ -97,22 +116,29 @@ vi.mock( ); vi.mock('../../../../packages/drawnix/src/components/media-library', () => ({ - MediaLibraryGrid: () => null, - MediaLibraryInspector: () => null, + MediaLibraryGrid: (props: unknown) => { + mediaLibraryGridMock(props); + return
; + }, + MediaLibraryInspector: (props: unknown) => { + mediaLibraryInspectorMock(props); + return
+ + ); +} + +async function createMirroredVideoRecordingStream( + inputStream: MediaStream, + livePreview?: HTMLVideoElement | null +): Promise<{ stream: MediaStream; cleanup: () => void } | null> { + const sourceVideoTracks = inputStream.getVideoTracks(); + if (!sourceVideoTracks.length) { + return null; + } + const canvas = document.createElement('canvas'); + if (typeof canvas.captureStream !== 'function') { + return null; + } + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + const video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + video.srcObject = new MediaStream(sourceVideoTracks); + + await video.play().catch(() => undefined); + if (!video.videoWidth || !video.videoHeight) { + await new Promise((resolve) => { + const done = () => resolve(); + video.addEventListener('loadedmetadata', done, { once: true }); + window.setTimeout(done, 300); + }); + } + + const settings = sourceVideoTracks[0]?.getSettings?.() || {}; + const width = + video.videoWidth || + livePreview?.videoWidth || + Number(settings.width) || + 1280; + const height = + video.videoHeight || + livePreview?.videoHeight || + Number(settings.height) || + 720; + const rawFrameRate = Number(settings.frameRate) || 30; + const frameRate = Math.min(60, Math.max(1, rawFrameRate)); + canvas.width = width; + canvas.height = height; + + let frameRequest = 0; + let stopped = false; + const drawFrame = () => { + if (stopped) { + return; + } + context.save(); + context.clearRect(0, 0, width, height); + context.translate(width, 0); + context.scale(-1, 1); + context.drawImage(video, 0, 0, width, height); + context.restore(); + frameRequest = window.requestAnimationFrame(drawFrame); + }; + drawFrame(); + + const canvasStream = canvas.captureStream(frameRate); + const stream = new MediaStream([ + ...canvasStream.getVideoTracks(), + ...inputStream.getAudioTracks(), + ]); + return { + stream, + cleanup: () => { + stopped = true; + if (frameRequest) { + window.cancelAnimationFrame(frameRequest); + } + canvasStream.getTracks().forEach((track) => track.stop()); + video.pause(); + video.srcObject = null; + }, + }; +} + function MediaRecorderPanel({ kind, title, @@ -2841,14 +4267,19 @@ function MediaRecorderPanel({ const previewRef = useRef(null); const recorderRef = useRef(null); const streamRef = useRef(null); + const recordingStreamCleanupRef = useRef<(() => void) | null>(null); const chunksRef = useRef([]); + const submittingPreparedRef = useRef(false); const [recording, setRecording] = useState(false); + const [startingRecording, setStartingRecording] = useState(false); const [streamReady, setStreamReady] = useState(false); const [devices, setDevices] = useState([]); const [deviceId, setDeviceId] = useState(''); const [preparedFile, setPreparedFile] = useState(null); const [preparedUrl, setPreparedUrl] = useState(''); + const [preparedMirrored, setPreparedMirrored] = useState(false); const [mirrored, setMirrored] = useState(true); + const [submittingPrepared, setSubmittingPrepared] = useState(false); const [message, setMessage] = useState(''); const attachLivePreview = (stream: MediaStream | null) => { @@ -2857,12 +4288,16 @@ function MediaRecorderPanel({ } previewRef.current.srcObject = stream; if (stream) { - void previewRef.current.play(); + void previewRef.current.play().catch(() => undefined); } }; const stopLiveCapture = (options: { updateState?: boolean } = {}) => { const { updateState = true } = options; + if (recordingStreamCleanupRef.current) { + recordingStreamCleanupRef.current(); + recordingStreamCleanupRef.current = null; + } const recorder = recorderRef.current; if (recorder) { recorder.ondataavailable = null; @@ -2886,6 +4321,7 @@ function MediaRecorderPanel({ } if (updateState) { setRecording(false); + setStartingRecording(false); setStreamReady(false); } }; @@ -2938,6 +4374,7 @@ function MediaRecorderPanel({ } setPreparedFile(null); setPreparedUrl(''); + setPreparedMirrored(false); }; const openStream = async () => { @@ -2969,16 +4406,34 @@ function MediaRecorderPanel({ setMessage('摄像头已打开,请确认画面后点击开始录制。'); return; } - startRecordingWithStream(stream); + void startRecordingWithStream(stream); } catch (error) { setStreamReady(false); setMessage(error instanceof Error ? error.message : '无法打开录制设备'); } }; - const startRecordingWithStream = (stream: MediaStream) => { + const startRecordingWithStream = async (stream: MediaStream) => { + if (recording || startingRecording) { + return; + } + setStartingRecording(true); try { chunksRef.current = []; + if (recordingStreamCleanupRef.current) { + recordingStreamCleanupRef.current(); + recordingStreamCleanupRef.current = null; + } + const mirroredSource = + kind === 'video' && mirrored + ? await createMirroredVideoRecordingStream(stream, previewRef.current) + : null; + if (mirroredSource) { + recordingStreamCleanupRef.current = mirroredSource.cleanup; + } + const recordingStream = mirroredSource?.stream || stream; + const recordedNeedsDisplayMirror = + kind === 'video' ? Boolean(mirrored && !mirroredSource) : false; const preferredMimeType = kind === 'video' ? [ @@ -3000,7 +4455,7 @@ function MediaRecorderPanel({ MediaRecorder.isTypeSupported(mimeType) ); const recorder = new MediaRecorder( - stream, + recordingStream, preferredMimeType ? { mimeType: preferredMimeType } : undefined ); recorderRef.current = recorder; @@ -3036,17 +4491,19 @@ function MediaRecorderPanel({ size: file.size, previewUrl: nextPreviewUrl, }); + const outputOptions = { mirrored: recordedNeedsDisplayMirror }; stopLiveCapture(); setPreparedFile(file); + setPreparedMirrored(recordedNeedsDisplayMirror); setPreparedUrl((current) => { if (current) { URL.revokeObjectURL(current); } return nextPreviewUrl; }); - onPrepared?.(file, nextPreviewUrl, { mirrored }); + onPrepared?.(file, nextPreviewUrl, outputOptions); if (autoSubmit) { - void onRecorded(file, { mirrored }).catch((error) => { + void onRecorded(file, outputOptions).catch((error) => { const message = error instanceof Error ? error.message : '录制文件导入失败'; setMessage(message); @@ -3064,14 +4521,20 @@ function MediaRecorderPanel({ attachLivePreview(stream); setMessage(kind === 'video' ? '摄像头录制中...' : '麦克风录音中...'); } catch (error) { + if (recordingStreamCleanupRef.current) { + recordingStreamCleanupRef.current(); + recordingStreamCleanupRef.current = null; + } setMessage(error instanceof Error ? error.message : '无法打开录制设备'); + } finally { + setStartingRecording(false); } }; const startRecording = async () => { if (kind === 'video' && streamRef.current) { clearPreparedPreview(); - startRecordingWithStream(streamRef.current); + void startRecordingWithStream(streamRef.current); return; } await openStream(); @@ -3086,15 +4549,19 @@ function MediaRecorderPanel({ }; const confirmPrepared = async () => { - if (!preparedFile) { + if (!preparedFile || importing || submittingPreparedRef.current) { return; } + submittingPreparedRef.current = true; + setSubmittingPrepared(true); setMessage('正在提交录制文件...'); try { - const result = await onRecorded(preparedFile, { mirrored }); + const result = await onRecorded(preparedFile, { + mirrored: preparedMirrored, + }); if (!result) { const message = - '录制文件已导入,但暂时没有可播放地址,请重新录制或上传文件。'; + '录制文件没有导入成功,请重新点击使用或改用本地上传。'; setMessage(message); MessagePlugin.warning(message); return; @@ -3112,11 +4579,15 @@ function MediaRecorderPanel({ } return ''; }); + setPreparedMirrored(false); } catch (error) { const message = error instanceof Error ? error.message : '录制文件提交失败'; setMessage(message); MessagePlugin.error(message); + } finally { + submittingPreparedRef.current = false; + setSubmittingPrepared(false); } }; @@ -3152,7 +4623,7 @@ function MediaRecorderPanel({
{preparedUrl ? (
@@ -3360,7 +4834,7 @@ function VoicePromptDialog({ } catch (error) { const message = error instanceof Error ? error.message : '音频导入失败'; setMessage(message); - MessagePlugin.error(message); + throw new Error(message); } finally { setImporting(false); } @@ -3379,7 +4853,11 @@ function VoicePromptDialog({ setMessage('请选择音频文件'); return; } - await acceptAudioFile(file); + await acceptAudioFile(file).catch((error) => { + MessagePlugin.error( + error instanceof Error ? error.message : '音频导入失败' + ); + }); }; const save = async () => { @@ -3568,6 +5046,8 @@ function AvatarTemplateDialog({ const [mirrored, setMirrored] = useState(Boolean(initialTemplate?.mirrored)); const [message, setMessage] = useState(''); const [importing, setImporting] = useState(false); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); const localPreviewUrlRef = useRef(''); const canSave = Boolean(name.trim() && videoUrl.trim()); @@ -3628,7 +5108,7 @@ function AvatarTemplateDialog({ } catch (error) { const message = error instanceof Error ? error.message : '视频导入失败'; setMessage(message); - MessagePlugin.error(message); + throw new Error(message); } finally { setImporting(false); } @@ -3647,13 +5127,19 @@ function AvatarTemplateDialog({ setMessage('请选择视频文件'); return; } - await acceptVideoFile(file); + await acceptVideoFile(file).catch((error) => { + MessagePlugin.error( + error instanceof Error ? error.message : '视频导入失败' + ); + }); }; const save = async () => { - if (!canSave) { + if (!canSave || savingRef.current) { return; } + savingRef.current = true; + setSaving(true); setMessage('正在保存形象...'); try { await onSave({ @@ -3671,6 +5157,9 @@ function AvatarTemplateDialog({ const message = error instanceof Error ? error.message : '形象保存失败'; setMessage(message); MessagePlugin.error(message); + } finally { + savingRef.current = false; + setSaving(false); } }; @@ -3791,10 +5280,10 @@ function AvatarTemplateDialog({ className="tg-primary-button" type="button" onClick={() => void save()} - disabled={!canSave || importing} + disabled={!canSave || importing || saving} > - 确认提交 + {saving ? '保存中' : '确认提交'} @@ -4232,6 +5721,9 @@ function runtimeStatusLabel(status?: string): string { function normalizeRuntimeBridgeStatus( status?: string ): RuntimeBridge['status'] | null { + if (status === 'adapter_ready') { + return 'ready'; + } const allowed: RuntimeBridge['status'][] = [ 'ready', 'planned', @@ -4246,6 +5738,35 @@ function normalizeRuntimeBridgeStatus( : null; } +function normalizeRuntimeBridge(bridge: RuntimeBridge): RuntimeBridge { + const normalizedStatus = + normalizeRuntimeBridgeStatus(String(bridge.status || '')) || 'planned'; + const normalizedSource = + bridge.source === 'opentu' || + bridge.source === 'aigcpanel' || + bridge.source === 'funclip' || + bridge.source === 'agent-reach' || + bridge.source === 'truegrowth' + ? bridge.source + : 'truegrowth'; + return { + ...bridge, + source: normalizedSource, + status: normalizedStatus, + functions: (bridge.functions || []).map((item) => ({ + ...item, + source: + item.source === 'opentu' || + item.source === 'aigcpanel' || + item.source === 'funclip' || + item.source === 'agent-reach' || + item.source === 'truegrowth' + ? item.source + : 'truegrowth', + })), + }; +} + function aigcPanelConnectionLabel( connection?: AigcPanelConnection | null ): string { @@ -4608,6 +6129,25 @@ function createGeneratedAsset(kind: 'image' | 'video', title: string): Asset { }; } +function mergeAssetsByIdentity(groups: Asset[][]): Asset[] { + const merged: Asset[] = []; + const seen = new Set(); + groups.flat().forEach((asset) => { + const keys = [ + asset.id ? `id:${asset.id}` : '', + asset.url ? `url:${asset.url}` : '', + asset.remoteUrl ? `url:${asset.remoteUrl}` : '', + asset.localPath ? `path:${asset.localPath}` : '', + ].filter(Boolean); + if (keys.some((key) => seen.has(key))) { + return; + } + keys.forEach((key) => seen.add(key)); + merged.push(asset); + }); + return merged.sort((a, b) => b.createdAt - a.createdAt); +} + function normalizeDrawnixAssetMimeType(asset: Asset): string { const explicitMimeType = assetMimeType(asset); if (explicitMimeType && !explicitMimeType.endsWith('/*')) { @@ -4747,6 +6287,97 @@ function drawnixAssetToPublishableAsset( }; } +function publishableAssetToDrawnixAsset( + asset: PublishableAsset +): DrawnixAsset | null { + const url = publishableAssetMediaUrl(asset); + if (!url) { + return null; + } + return { + id: asset.assetId || asset.id, + type: + asset.type === 'video' ? DrawnixAssetType.VIDEO : DrawnixAssetType.IMAGE, + source: + asset.source === 'asset-library' || asset.source === 'local-upload' + ? DrawnixAssetSource.LOCAL + : DrawnixAssetSource.AI_GENERATED, + category: asset.sourceTags?.includes('digital-human') + ? DrawnixAssetCategory.CHARACTER + : DrawnixAssetCategory.GENERAL, + url, + name: asset.name, + mimeType: asset.type === 'video' ? 'video/mp4' : 'image/png', + createdAt: asset.createdAt, + thumbnail: publishableAssetThumbnailUrl(asset), + localPath: asset.localPath, + taskId: asset.sourceRefs.find((ref) => ref.type === 'task')?.id, + duration: asset.durationMs + ? Math.round(asset.durationMs / 1000) + : undefined, + sourceTags: asset.sourceTags, + publishState: asset.publishState, + }; +} + +function drawnixAssetToFunClipSource( + asset: DrawnixAsset +): FunClipSource | null { + if ( + asset.type !== DrawnixAssetType.VIDEO && + asset.type !== DrawnixAssetType.AUDIO && + asset.type !== DrawnixAssetType.IMAGE + ) { + return null; + } + const url = assetMediaUrl(asset); + if (!url) { + return null; + } + + const tags = new Set(asset.sourceTags || []); + if (asset.source === DrawnixAssetSource.LOCAL) { + tags.add('local'); + } + if (asset.source === DrawnixAssetSource.AI_GENERATED) { + tags.add('ai'); + } + if (asset.publishState === 'published') { + tags.add('published'); + } + const normalizedTags = Array.from(tags); + const section: FunClipSource['section'] = + asset.publishState === 'published' || normalizedTags.includes('published') + ? 'published' + : normalizedTags.some( + (tag) => tag === 'ai' || tag === 'clip' || tag === 'digital-human' + ) || asset.source === DrawnixAssetSource.AI_GENERATED + ? 'work' + : 'material'; + + return { + id: `asset-center-${asset.id}`, + assetId: asset.id, + name: asset.name, + type: + asset.type === DrawnixAssetType.AUDIO + ? 'audio' + : asset.type === DrawnixAssetType.IMAGE + ? 'image' + : 'video', + source: 'asset-center', + section, + tags: normalizedTags, + provider: + asset.source === DrawnixAssetSource.LOCAL ? '本地资产' : '资产中心', + url, + thumbnailUrl: assetThumbnailUrl(asset), + localPath: asset.localPath, + durationMs: asset.duration ? asset.duration * 1000 : undefined, + createdAt: asset.createdAt || Date.now(), + }; +} + function createInitialSettingsSnapshot(): SettingsSnapshot { return { baseUrl: '加载中', @@ -4922,6 +6553,21 @@ function friendlyFunClipRuntimeError(error?: unknown): string { return '剪辑服务启动失败,请稍后重试。'; } +function friendlyModelCenterMessage(value?: unknown): string { + const message = value instanceof Error ? value.message : String(value || ''); + if (!message.trim()) { + return ''; + } + if ( + /fetch failed|failed to fetch|load failed|networkerror|econnrefused|connection refused|abort|timeout/i.test( + message + ) + ) { + return '本地图片服务暂未启动,启动后会自动读取已导入模型。'; + } + return message; +} + function isConfiguredSettingsValue(value: string): boolean { const normalized = value.trim(); return Boolean( @@ -4929,16 +6575,77 @@ function isConfiguredSettingsValue(value: string): boolean { ); } +function inferModelVendorFromId(modelId: string): ModelVendor { + const normalized = modelId.toLowerCase(); + if (normalized.includes('gpt') || normalized.includes('openai')) { + return ModelVendor.GPT; + } + if (normalized.includes('claude') || normalized.includes('anthropic')) { + return ModelVendor.ANTHROPIC; + } + if (normalized.includes('gemini') || normalized.includes('google')) { + return ModelVendor.GOOGLE; + } + if (normalized.includes('deepseek')) { + return ModelVendor.DEEPSEEK; + } + if (normalized.includes('qwen') || normalized.includes('通义')) { + return ModelVendor.QWEN; + } + if (normalized.includes('doubao') || normalized.includes('豆包')) { + return ModelVendor.DOUBAO; + } + if (normalized.includes('glm') || normalized.includes('智谱')) { + return ModelVendor.GLM; + } + if (normalized.includes('grok')) { + return ModelVendor.GROK; + } + return ModelVendor.OTHER; +} + +function createConfiguredTextModelOption(input: { + modelId: string; + profileId?: string | null; + profileName?: string | null; +}): ModelConfig { + const modelId = input.modelId.trim(); + return { + id: modelId, + label: modelId, + shortLabel: modelId, + type: 'text', + vendor: inferModelVendorFromId(modelId), + description: input.profileName + ? `${input.profileName} · 系统当前文本模型` + : '系统当前文本模型', + sourceProfileId: input.profileId || null, + sourceProfileName: input.profileName || '系统文本模型', + selectionKey: input.profileId + ? `${input.profileId}:${modelId}` + : `configured:${modelId}`, + }; +} + export default function WorkbenchShell() { const { confirm, confirmDialog } = useWorkbenchConfirm(); const navRef = useRef(null); const runtimeBridge = getTrueGrowthRuntimeBridge(); const desktopWindowControls = runtimeBridge?.windowControls || null; const desktopThemeBridge = runtimeBridge?.theme || null; + const desktopUpdateBridge = runtimeBridge?.updates || null; const [desktopWindowState, setDesktopWindowState] = useState(() => normalizeWindowState({ platform: runtimeBridge?.platform }) ); + const [desktopUpdateState, setDesktopUpdateState] = + useState(() => + normalizeDesktopUpdateState({ + platform: runtimeBridge?.platform, + status: desktopUpdateBridge ? 'idle' : undefined, + }) + ); + const [desktopUpdateDialogOpen, setDesktopUpdateDialogOpen] = useState(false); const [activeRoute, setActiveRoute] = useState(() => { if (typeof window === 'undefined') { return '/home'; @@ -4992,6 +6699,8 @@ export default function WorkbenchShell() { message?: string; command?: string; setup?: SocialPublishingSetup; + progress?: SocialPublishingSetupProgress; + preparing?: boolean; activeJobs?: number; accountLimit?: number; platformLimit?: number; @@ -5115,6 +6824,28 @@ export default function WorkbenchShell() { }; }, [desktopWindowControls]); + useEffect(() => { + if (!desktopUpdateBridge) { + return undefined; + } + let cancelled = false; + desktopUpdateBridge + .getState?.() + ?.then((state) => { + if (!cancelled) { + setDesktopUpdateState(normalizeDesktopUpdateState(state)); + } + }) + .catch(() => undefined); + const unsubscribe = desktopUpdateBridge.onStateChange?.((state) => { + setDesktopUpdateState(normalizeDesktopUpdateState(state)); + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [desktopUpdateBridge]); + useEffect(() => { if (comfyUiSetup?.vendorPath && !comfyUiVendorPathInput) { setComfyUiVendorPathInput(comfyUiSetup.vendorPath); @@ -5386,6 +7117,27 @@ export default function WorkbenchShell() { return () => window.removeEventListener('popstate', syncRouteFromLocation); }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + const handleTimelineHandoff = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.asset) { + setTimelineHandoff(detail); + } + }; + window.addEventListener( + TRUEGROWTH_TIMELINE_HANDOFF_EVENT, + handleTimelineHandoff + ); + return () => + window.removeEventListener( + TRUEGROWTH_TIMELINE_HANDOFF_EVENT, + handleTimelineHandoff + ); + }, []); + useEffect(() => { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 3500); @@ -5422,10 +7174,17 @@ export default function WorkbenchShell() { serviceUrl?: string; error?: string; }; + videoagent?: { + connected?: boolean; + status?: string; + serviceUrl?: string; + error?: string; + }; }; }) => { const aigcpanelConnection = body.connections?.aigcpanel; - const funclipConnection = body.connections?.funclip; + const clipConnection = + body.connections?.videoagent || body.connections?.funclip; setLocalRuntimeConnected(true); setRuntimeStatus((items) => items.map((item) => { @@ -5464,15 +7223,23 @@ export default function WorkbenchShell() { '等待内置运行时准备模型调用能力', }; } - if (item.id === 'funclip-runtime') { + if ( + item.id === 'videoagent-runtime' || + item.id === 'funclip-runtime' + ) { return { ...item, - status: funclipConnection?.connected ? 'ready' : 'offline', - detail: funclipConnection?.connected - ? `FunClip 服务已连接:${ - funclipConnection.serviceUrl || 'http://127.0.0.1:7860' - }` - : 'FunClip 服务未启动,启动后即可使用字幕识别与智能剪辑', + id: 'videoagent-runtime', + status: + clipConnection?.connected || + clipConnection?.status === 'adapter_ready' + ? 'ready' + : 'offline', + detail: + clipConnection?.connected || + clipConnection?.status === 'adapter_ready' + ? '基础剪辑流程已可使用,高级能力可在模型中心继续准备' + : '剪辑能力准备后即可使用字幕识别、内容理解与智能剪辑', }; } return item; @@ -5522,8 +7289,7 @@ export default function WorkbenchShell() { if (cancelled) { return; } - setLocalAssets([]); - setLocalTasks([]); + setLocalRuntimeConnected(false); }); return () => { cancelled = true; @@ -5658,6 +7424,11 @@ export default function WorkbenchShell() { message: body.connection.message, command: body.connection.command, setup: body.connection.setup, + progress: + body.connection.progress || body.connection.setup?.progress, + preparing: + (body.connection.progress || body.connection.setup?.progress) + ?.status === 'running', activeJobs: body.concurrency?.activeJobs, accountLimit: body.concurrency?.accountLimit, platformLimit: body.concurrency?.platformLimit, @@ -5677,7 +7448,7 @@ export default function WorkbenchShell() { ) { return items; } - const nextBridge: RuntimeBridge = { + const nextBridge = normalizeRuntimeBridge({ id: String(body.bridge.id), name: String(body.bridge.name), source: body.bridge.source as RuntimeBridge['source'], @@ -5698,7 +7469,7 @@ export default function WorkbenchShell() { functions: Array.isArray(body.bridge.functions) ? (body.bridge.functions as RuntimeBridge['functions']) : [], - }; + }); const exists = items.some((item) => item.id === nextBridge.id); return exists ? items.map((item) => @@ -5709,6 +7480,52 @@ export default function WorkbenchShell() { } }, []); + const prepareSocialPublishing = useCallback(async () => { + setSocialBridgeStatus((current) => ({ + ...current, + preparing: true, + status: current.status === 'connected' ? current.status : 'needs_setup', + message: '正在准备发布授权环境,首次使用会自动完成。', + progress: { + ...(current.progress || {}), + status: 'running', + phase: current.progress?.phase || 'runtime', + message: '正在准备发布授权环境,首次使用会自动完成。', + }, + })); + try { + const result = await prepareSocialPublishingRuntime(); + setSocialBridgeStatus((current) => ({ + ...current, + available: Boolean(result.ok), + status: result.status || (result.ok ? 'connected' : current.status), + message: result.message || current.message, + setup: result.setup || current.setup, + progress: result.progress || current.progress, + preparing: Boolean(result.running) || result.progress?.status === 'running', + })); + await refreshSocialPublishing(); + if (result.ok) { + MessagePlugin.success('发布授权环境已准备完成'); + } else if (result.running || result.progress?.status === 'running') { + MessagePlugin.info('发布授权环境正在准备中,可继续查看进度。'); + } else { + MessagePlugin.warning( + friendlyPublishRuntimeMessage(result.message) || + '发布授权环境仍在准备中,请稍后重试。' + ); + } + } catch { + setSocialBridgeStatus((current) => ({ + ...current, + preparing: false, + status: current.available ? current.status : 'needs_setup', + message: '发布授权环境准备响应较慢,请稍后重试或检查本机服务状态。', + })); + MessagePlugin.warning('发布授权环境准备响应较慢,请稍后重试。'); + } + }, [refreshSocialPublishing]); + const subscribeSocialEvents = useCallback((pathname: string) => { if (typeof window === 'undefined' || typeof EventSource === 'undefined') { return; @@ -5744,6 +7561,7 @@ export default function WorkbenchShell() { }; [ 'login_progress', + 'setup_progress', 'login_ready', 'login_action_required', 'campaign_status', @@ -5782,27 +7600,36 @@ export default function WorkbenchShell() { (target) => target.status === 'queued' || target.status === 'running' ) ); - if (activeRoute !== '/publish' && !hasActivePublishing) { + if ( + activeRoute !== '/publish' && + !hasActivePublishing && + !socialBridgeStatus.preparing + ) { return undefined; } const intervalId = window.setInterval(() => { refreshSocialPublishing().catch(() => undefined); - }, 5000); + }, socialBridgeStatus.preparing ? 1800 : 5000); return () => window.clearInterval(intervalId); - }, [activeRoute, publishCampaigns, refreshSocialPublishing]); + }, [ + activeRoute, + publishCampaigns, + refreshSocialPublishing, + socialBridgeStatus.preparing, + ]); const funclipBridgeStatus = runtimeBridges.find( - (item) => item.id === 'funclip-local' + (item) => item.id === 'videoagent-local' )?.status; const refreshFunClipRuntime = useCallback(async () => { const controller = new AbortController(); try { - const response = await fetch('http://127.0.0.1:48177/local-api/funclip', { + const response = await fetch(localApiUrl('/local-api/videoagent'), { signal: controller.signal, }); if (!response.ok) { - throw new Error(`funclip probe failed: ${response.status}`); + throw new Error(`clip probe failed: ${response.status}`); } const body = (await response.json()) as { runtime?: { @@ -5819,21 +7646,29 @@ export default function WorkbenchShell() { }; const nextRuntime = { status: body.runtime?.status || 'planned', - serviceUrl: body.runtime?.serviceUrl || 'http://127.0.0.1:7860', - launchCommand: body.runtime?.launchCommand || 'pnpm funclip:dev', - vendorPath: body.runtime?.vendorPath || 'vendor/funclip', + serviceUrl: body.runtime?.serviceUrl || 'http://127.0.0.1:7870', + launchCommand: body.runtime?.launchCommand || '', + vendorPath: body.runtime?.vendorPath || '', readiness: body.connection?.readiness || body.runtime?.readiness, }; setFunclipRuntime(nextRuntime); funclipRuntimeRef.current = nextRuntime; const bridge = body.bridge; if (bridge) { - setRuntimeBridges((items) => - items.map((item) => (item.id === bridge.id ? bridge : item)) - ); + const nextBridge = normalizeRuntimeBridge(bridge); + setRuntimeBridges((items) => { + const exists = items.some((item) => item.id === nextBridge.id); + return exists + ? items.map((item) => + item.id === nextBridge.id ? { ...item, ...nextBridge } : item + ) + : [nextBridge, ...items]; + }); } const isReady = - body.runtime?.status === 'ready' || bridge?.status === 'ready'; + body.runtime?.status === 'ready' || + body.runtime?.status === 'adapter_ready' || + bridge?.status === 'ready'; funclipReadyRef.current = isReady; if (isReady) { funclipRetryCountRef.current = 0; @@ -5850,15 +7685,19 @@ export default function WorkbenchShell() { } }, []); - const ensureFunClipRuntime = useCallback(async () => { + const ensureFunClipRuntime = useCallback(async (options?: { quick?: boolean }) => { const alreadyReady = funclipReadyRef.current || funclipRuntime?.status === 'ready' || + funclipRuntime?.status === 'adapter_ready' || funclipBridgeStatus === 'ready'; if (alreadyReady) { setFunclipRuntimeError(''); return true; } + if (options?.quick) { + return refreshFunClipRuntime(); + } if (funclipStartInFlightRef.current) { return funclipStartInFlightRef.current; } @@ -5873,11 +7712,11 @@ export default function WorkbenchShell() { if (funclipRuntimeRef.current?.status === 'setup_required') { setFunclipRuntimeError( funclipRuntimeRef.current.readiness?.message || - '剪辑源码已集成,仍有本机运行组件需要完成一次性准备。' + '剪辑能力已集成,仍有高级能力需要完成一次性准备。' ); return false; } - await startFunClipRuntime(); + await startVideoAgentRuntime(); for (let attempt = 0; attempt < 5; attempt += 1) { await waitForWorkbench(1200); const readyAfterStart = await refreshFunClipRuntime(); @@ -5900,7 +7739,7 @@ export default function WorkbenchShell() { }, [funclipBridgeStatus, funclipRuntime?.status, refreshFunClipRuntime]); useEffect(() => { - void ensureFunClipRuntime(); + void ensureFunClipRuntime({ quick: true }); }, [ensureFunClipRuntime]); useEffect(() => { @@ -5934,8 +7773,11 @@ export default function WorkbenchShell() { if (!bridge) { return; } + const nextBridge = normalizeRuntimeBridge(bridge); setRuntimeBridges((items) => - items.map((item) => (item.id === bridge.id ? bridge : item)) + items.map((item) => + item.id === nextBridge.id ? { ...item, ...nextBridge } : item + ) ); }) .catch(() => { @@ -5962,7 +7804,7 @@ export default function WorkbenchShell() { }; }, [refreshSettingsSnapshot]); - const activeTasks = tasks.filter((task) => task.status === 'running').length; + const activeTasks = tasks.filter(isUnifiedTaskActive).length; const completedToday = tasks.filter( (task) => task.status === 'completed' ).length; @@ -5979,7 +7821,9 @@ export default function WorkbenchShell() { ); const funclipBridge = useMemo( () => - runtimeBridges.find((bridge) => bridge.id === 'funclip-local') || null, + runtimeBridges.find((bridge) => bridge.id === 'videoagent-local') || + runtimeBridges.find((bridge) => bridge.id === 'funclip-local') || + null, [runtimeBridges] ); @@ -5987,6 +7831,10 @@ export default function WorkbenchShell() { () => normalizePublishableAssets(localAssets, localTasks, publishCampaigns), [localAssets, localTasks, publishCampaigns] ); + const clipWorkbenchAssets = useMemo( + () => mergeAssetsByIdentity([localAssets, assets]), + [assets, localAssets] + ); const navigate = (route: string, defaultMode?: string) => { const legacyRoute = LEGACY_APP_CENTER_ROUTES[route]; @@ -6173,7 +8021,7 @@ export default function WorkbenchShell() { } } if (templatesResult.status === 'fulfilled') { - setVideoTemplates(templates); + setVideoTemplates(dedupeVideoTemplatesByIdentity(templates)); } if (promptsResult.status === 'fulfilled') { setSoundPrompts(prompts); @@ -6229,7 +8077,7 @@ export default function WorkbenchShell() { } if (templates.length || prompts.length) { upsertAssets([ - ...templates.map(templateToAsset), + ...dedupeVideoTemplatesByIdentity(templates).map(templateToAsset), ...prompts.map(soundPromptToAsset), ]); } @@ -6772,10 +8620,18 @@ export default function WorkbenchShell() { } }; - const uploadAndInstallModelAsset = async (assetId: string, file: File) => { + const uploadAndInstallModelAsset = async ( + assetId: string, + file: File, + onProgress?: (progress: RuntimeDownloadProgress) => void + ) => { setInstallingModelIds((current) => new Set(current).add(assetId)); try { - const result = await uploadAndInstallAigcPanelModelAsset(assetId, file); + const result = await uploadAndInstallAigcPanelModelAsset( + assetId, + file, + onProgress + ); await refreshAigcPanelResources(); MessagePlugin.success('模型包已导入,数字人和声音表单已同步刷新'); return result; @@ -6860,8 +8716,8 @@ export default function WorkbenchShell() { payload?: Record ) => { const taskInput: SubmitLocalRuntimeTaskInput = { - endpoint: '/local-api/funclip/tasks', - providerId: 'funclip-local', + endpoint: '/local-api/videoagent/tasks', + providerId: 'videoagent-local', capability: mode === 'recognize' ? 'clip.analyze' : 'clip.compose', title, payload: { @@ -6873,11 +8729,7 @@ export default function WorkbenchShell() { : mode === 'clip' ? 'clip' : 'llmHighlight', - bridge: '/local-api/funclip', - command: - mode === 'recognize' - ? 'python3 vendor/funclip/funclip/videoclipper.py --stage 1' - : 'python3 vendor/funclip/funclip/videoclipper.py --stage 2', + bridge: '/local-api/videoagent', }, }; try { @@ -7153,7 +9005,23 @@ export default function WorkbenchShell() { mirrored?: boolean; durationMs?: number; }) => { - const createdTemplate = await createAigcPanelVideoTemplate(input); + const existingTemplate = videoTemplates.find((template) => + sameVideoTemplateMedia(template, input) + ); + const createdTemplate = await createAigcPanelVideoTemplate({ + ...input, + id: existingTemplate?.id, + createdAt: existingTemplate?.createdAt, + }); + setVideoTemplates((current) => + dedupeVideoTemplatesByIdentity([ + createdTemplate, + ...current.filter( + (item) => + item.id !== createdTemplate.id && !sameVideoTemplateMedia(item, input) + ), + ]) + ); setHiddenVideoTemplateIds((current) => { const next = new Set(current); next.delete(createdTemplate.id); @@ -7350,7 +9218,7 @@ export default function WorkbenchShell() { return wrapFirstLevelPage( '/timeline', navigate('/models')} onOpenPublish={() => navigate('/publish')} onEnsureRuntime={ensureFunClipRuntime} + onRefreshRuntimeState={refreshLocalRuntimeState} onImportLocalFiles={importClipInputFiles} onCreateClipTask={createFunClipTask} /> @@ -7476,6 +9345,10 @@ export default function WorkbenchShell() { onOpenPublish={() => navigate('/publish')} assets={assets} publishableAssets={publishableAssets} + onDeleteRuntimeAsset={async (assetId) => { + await deleteLocalAsset(assetId); + await refreshLocalRuntimeState(); + }} /> ); @@ -7495,11 +9368,17 @@ export default function WorkbenchShell() { { + await deleteLocalAsset(assetId); + await refreshLocalRuntimeState(); + }} onCreateAccount={async (input) => { const optimisticAccountId = `account-${ input.platform @@ -7739,6 +9618,9 @@ export default function WorkbenchShell() { '/settings', setDesktopUpdateDialogOpen(true)} snapshot={settingsSnapshot} onOpenModels={() => navigate('/models')} /> @@ -7794,6 +9676,12 @@ export default function WorkbenchShell() { ))} + {desktopUpdateBridge ? ( + setDesktopUpdateDialogOpen(true)} + /> + ) : null}
); }) ) : ( @@ -10999,7 +14511,7 @@ function FunClipEditingPage({ onClick={() => { setClipHistoryOpen(false); setServiceStatusOpen(false); - setStep('ability'); + setStep('source'); }} > @@ -11010,12 +14522,69 @@ function FunClipEditingPage({
) : null} + {previewSource ? ( + setPreviewSource(null)} + onUse={selectSourceOnly} + /> + ) : null} + + {sourcePickerOpen ? ( + + setSourcePickerOpen(false)} + mode={SelectionMode.SELECT} + filterType={sourcePickerFilterType} + extraAssets={sourceAssetPickerExtraAssets} + onSelect={handleAssetCenterSelect} + onSelectMultiple={ + (sourcePickerOpen === 'references' && canUseReferenceSources) || + (sourcePickerOpen === 'primary' && allowsDirectMultiSelect) + ? handleAssetCenterSelectMultiple + : undefined + } + onDeleteAsset={handleAssetCenterDelete} + defaultSelectionModeActive={ + (sourcePickerOpen === 'references' && canUseReferenceSources) || + (sourcePickerOpen === 'primary' && allowsDirectMultiSelect) + } + selectButtonText={ + sourcePickerOpen === 'references' + ? '加入参考素材' + : allowsDirectMultiSelect + ? '加入素材' + : '使用这个主视频' + } + batchSelectButtonText={ + sourcePickerOpen === 'references' + ? '加入所选参考素材' + : '加入所选素材' + } + selectionModeTitle={ + sourcePickerOpen === 'references' + ? '选择参考素材' + : allowsDirectMultiSelect + ? '多选剪辑素材' + : '选择主视频' + } + selectionModeDescription={ + sourcePickerOpen === 'references' + ? '可多选图片、视频或音频作为参考素材;主视频不会被替换。' + : allowsDirectMultiSelect + ? '可多选图片、视频或音频,加入后第一项会作为主素材,也可以在右侧列表重新指定。' + : '请选择 1 个作为本次任务的主视频;需要参考素材时回到右侧继续添加。' + } + /> + + ) : null} +
{[ - ['ability', '能力'], - ['source', '视频'], - ['config', '配置'], + ['source', '制作'], ['editor', '编辑'], ['preview', '预览'], ].map(([id, label], index) => { @@ -11152,122 +14721,259 @@ function FunClipEditingPage({ ) : null} {step === 'source' ? ( -
+
-

选择视频或音频

- 可上传本地文件,也可从资产中心继续加工。 +

你想把这段素材做成什么?

+ + 选择一个快捷场景会自动写入目标和配置;也可以直接改成自己的需求。 +
- {sourceOptions.length} 个可用媒体 +
-
- {[ - [ - 'asset-center', - '资产中心', - , - ], - ['upload', '本地上传', ], - ].map(([id, label, icon]) => ( - - ))} -
- {sourceTab === 'upload' ? ( -
- - 导入本地视频或音频 - 导入后会自动选中,可直接进入成片配置。 - - {uploadMessage ? {uploadMessage} : null} -
- ) : ( - <> -
- {sourceFilterOptions.map((item) => ( + +
+