diff --git a/MCP_README.md b/MCP_README.md new file mode 100644 index 00000000..0d32fac1 --- /dev/null +++ b/MCP_README.md @@ -0,0 +1,152 @@ +# LTX-Desktop MCP Server + +An MCP (Model Context Protocol) server that wraps [LTX-Desktop](https://github.com/Lightricks/LTX-Desktop)'s AI video generation backend and project editing capabilities, enabling AI assistants to automate video creation and timeline editing. + +## Features + +| Capability | Description | +|---|---| +| 🎬 Video Generation | Text-to-video, image-to-video, audio-to-video | +| 🖼️ Image Generation | Text-to-image with configurable resolution | +| 🔄 Video Retake | AI re-generation of specific video segments | +| 🎞️ Timeline Editing | Full timeline manipulation via project JSON | +| ✂️ Clip Control | Trim, speed, reverse, opacity, volume | +| 🎨 Color Grading | Brightness, contrast, saturation, temperature, exposure | +| ✨ Effects | Blur, sharpen, glow, vignette, grain, LUT presets | +| 🔤 Text & Subtitles | Text overlays and subtitle tracks | +| 🔀 Transitions | Dissolve, fade, wipe (8 types) | +| 📦 Export | FFmpeg-based video export (H.264, ProRes, VP9) | + +## Architecture + +``` +┌─────────────────┐ stdio ┌──────────────────┐ +│ AI Assistant │◄─────────────►│ MCP Server │ +│ (Claude, etc.) │ │ ltx_mcp_server.py│ +└─────────────────┘ └────┬────────┬─────┘ + │ │ + HTTP :8000│ │HTTP :8100 + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ Python │ │ Electron │ + │ Backend │ │ Project │ + │ (FastAPI)│ │ Bridge │ + └──────────┘ └──────────────┘ + Video Gen Timeline JSON + Model Mgmt Export/Import +``` + +## Prerequisites + +- [LTX-Desktop](https://github.com/Lightricks/LTX-Desktop) installed and running +- Python 3.10+ +- NVIDIA GPU with ≥32GB VRAM (for local generation) + +## Installation + +```bash +# 1. Install Python dependencies +pip install mcp httpx + +# 2. Install LTX-Desktop dependencies +cd /path/to/LTX-Desktop +pnpm install + +# 3. Start LTX-Desktop +pnpm dev +``` + +## Usage + +### Configure in your AI assistant + +Add to your MCP configuration (e.g. Claude Desktop `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "ltx-desktop": { + "command": "python3", + "args": ["/path/to/LTX-Desktop/ltx_mcp_server.py"], + "env": { + "LTX_BACKEND_URL": "http://127.0.0.1:8000", + "LTX_BRIDGE_URL": "http://127.0.0.1:8100" + } + } + } +} +``` + +### Available MCP Tools + +#### Video Generation (Backend API) + +| Tool | Description | +|---|---| +| `generate_video` | Generate video from text/image/audio | +| `generate_image` | Generate images from text | +| `retake_video` | Re-generate a portion of a video | +| `suggest_gap_prompt` | AI-suggest prompt for timeline gaps | +| `get_generation_progress` | Check generation progress | +| `cancel_generation` | Cancel running generation | + +#### Model Management + +| Tool | Description | +|---|---| +| `get_health` | Backend health & GPU status | +| `get_models_status` | Model download status | +| `download_models` | Trigger model downloads | + +#### Project & Timeline Editing (Electron Bridge) + +| Tool | Description | +|---|---| +| `list_projects` | List all projects | +| `get_project` | Get full project JSON | +| `update_project` | Update project (timeline, clips, etc.) | +| `export_video` | Export timeline as video file | +| `import_asset` | Import local file into project | + +### Example Workflow + +``` +User: "Generate 3 video clips about a sunset and arrange them on the timeline with dissolve transitions" + +AI Assistant: +1. generate_video(prompt="golden sunset over ocean, waves crashing") → clip1.mp4 +2. generate_video(prompt="sun dipping below horizon, orange sky") → clip2.mp4 +3. generate_video(prompt="twilight sky with first stars appearing") → clip3.mp4 +4. get_project(project_id) → read current project +5. Add 3 assets + 3 clips to timeline with: + - clip1: startTime=0, transitionOut={type: "dissolve", duration: 0.5} + - clip2: startTime=5, transitionIn/Out={type: "dissolve", duration: 0.5} + - clip3: startTime=10, transitionIn={type: "dissolve", duration: 0.5} +6. update_project(project_id, modified_json) +7. export_video(project_id, "/home/user/sunset_montage.mp4") +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `LTX_BACKEND_URL` | `http://127.0.0.1:8000` | LTX-Desktop FastAPI backend URL | +| `LTX_BRIDGE_URL` | `http://127.0.0.1:8100` | Electron project bridge URL | +| `LTX_TIMEOUT` | `600` | Request timeout in seconds | + +## Project Structure (MCP additions) + +``` +LTX-Desktop/ +├── ltx_mcp_server.py # MCP Server (standalone script) +├── electron/ +│ ├── project-bridge.ts # HTTP bridge for project JSON access +│ └── main.ts # Modified: registers project bridge +└── .agents/skills/ + └── ltx-desktop-mcp/ + └── SKILL.md # AI assistant skill documentation +``` + +## License + +This MCP integration follows the same license as [LTX-Desktop](https://github.com/Lightricks/LTX-Desktop). diff --git a/electron/main.ts b/electron/main.ts index e79e0130..1b764c27 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,6 +8,7 @@ import { registerFileHandlers } from './ipc/file-handlers' import { registerLogHandlers } from './ipc/log-handlers' import { registerVideoProcessingHandlers } from './ipc/video-processing-handlers' import { initSessionLog } from './logging-management' +import { startProjectBridge, stopProjectBridge } from './project-bridge' import { stopPythonBackend } from './python-backend' import { initAutoUpdater } from './updater' import { createWindow, getMainWindow } from './window' @@ -56,6 +57,7 @@ if (!gotLock) { setupCSP() createWindow() initAutoUpdater() + startProjectBridge() // Python setup + backend start are now driven by the renderer via IPC // Fire analytics event (no-op if user hasn't opted in) @@ -77,6 +79,7 @@ if (!gotLock) { app.on('before-quit', () => { stopExportProcess() + stopProjectBridge() stopPythonBackend() }) } diff --git a/electron/project-bridge.ts b/electron/project-bridge.ts new file mode 100644 index 00000000..6e96103c --- /dev/null +++ b/electron/project-bridge.ts @@ -0,0 +1,264 @@ +/** + * Project Bridge — lightweight HTTP server (port 8100) that exposes + * project data from the renderer's localStorage to external tools + * (MCP Server, scripts, etc.). + * + * Endpoints: + * GET /api/projects — list all projects + * GET /api/projects/:id — get a single project + * PUT /api/projects/:id — update a single project + * POST /api/export — trigger FFmpeg export + * POST /api/import-asset — copy a file into project assets + */ + +import http from 'http' +import { getMainWindow } from './window' +import { logger } from './logger' + +const BRIDGE_PORT = 8100 + +let bridgeServer: http.Server | null = null + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function jsonResponse(res: http.ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }) + res.end(payload) +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + req.on('error', reject) + }) +} + +/** Execute JS in the renderer and return the result. */ +async function rendererEval(js: string): Promise { + const win = getMainWindow() + if (!win) throw new Error('Main window not available') + return win.webContents.executeJavaScript(js) as Promise +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +async function handleListProjects(res: http.ServerResponse): Promise { + try { + const projects = await rendererEval( + `JSON.parse(localStorage.getItem('ltx-projects') || '[]')` + ) + jsonResponse(res, 200, projects) + } catch (err) { + jsonResponse(res, 500, { error: String(err) }) + } +} + +async function handleGetProject(res: http.ServerResponse, projectId: string): Promise { + try { + const projects = await rendererEval>( + `JSON.parse(localStorage.getItem('ltx-projects') || '[]')` + ) + const project = projects.find((p) => p.id === projectId) + if (!project) { + jsonResponse(res, 404, { error: `Project not found: ${projectId}` }) + return + } + jsonResponse(res, 200, project) + } catch (err) { + jsonResponse(res, 500, { error: String(err) }) + } +} + +async function handleUpdateProject( + req: http.IncomingMessage, + res: http.ServerResponse, + projectId: string +): Promise { + try { + const body = await readBody(req) + const updatedProject = JSON.parse(body) + + // Read current projects, replace the matching one + const projects = await rendererEval>( + `JSON.parse(localStorage.getItem('ltx-projects') || '[]')` + ) + const idx = projects.findIndex((p) => p.id === projectId) + if (idx === -1) { + jsonResponse(res, 404, { error: `Project not found: ${projectId}` }) + return + } + + // Ensure the project ID is preserved + updatedProject.id = projectId + updatedProject.updatedAt = Date.now() + projects[idx] = updatedProject + + // Write back to localStorage + const escaped = JSON.stringify(JSON.stringify(projects)) + await rendererEval(`localStorage.setItem('ltx-projects', ${escaped})`) + + // Trigger a storage event so React picks up the change + await rendererEval( + `window.dispatchEvent(new StorageEvent('storage', { key: 'ltx-projects' }))` + ) + + // Force React re-render by dispatching a custom reload event + await rendererEval( + `window.dispatchEvent(new CustomEvent('ltx-projects-reload'))` + ) + + jsonResponse(res, 200, { status: 'updated', id: projectId }) + } catch (err) { + jsonResponse(res, 500, { error: String(err) }) + } +} + +async function handleExport( + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + try { + const body = await readBody(req) + const params = JSON.parse(body) + + const { projectId, outputPath, width, height, fps, codec, quality } = params + + // Read the project and active timeline + const projects = await rendererEval; activeTimelineId?: string }>>( + `JSON.parse(localStorage.getItem('ltx-projects') || '[]')` + ) + const project = projects.find((p) => p.id === projectId) + if (!project) { + jsonResponse(res, 404, { error: `Project not found: ${projectId}` }) + return + } + + const timeline = project.timelines?.find((t) => t.id === project.activeTimelineId) || project.timelines?.[0] + if (!timeline || !timeline.clips?.length) { + jsonResponse(res, 400, { error: 'No clips in timeline to export' }) + return + } + + // Delegate to the existing export-native IPC handler by invoking it via renderer + const exportData = { + clips: timeline.clips, + outputPath, + codec: codec || 'h264', + width: width || 1920, + height: height || 1080, + fps: fps || 24, + quality: quality || 80, + } + + const escaped = JSON.stringify(JSON.stringify(exportData)) + const result = await rendererEval<{ success?: boolean; error?: string }>( + `window.electronAPI.exportNative(JSON.parse(${escaped}))` + ) + jsonResponse(res, result?.success ? 200 : 500, result) + } catch (err) { + jsonResponse(res, 500, { error: String(err) }) + } +} + +async function handleImportAsset( + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + try { + const body = await readBody(req) + const { projectId, filePath } = JSON.parse(body) + + if (!projectId || !filePath) { + jsonResponse(res, 400, { error: 'projectId and filePath are required' }) + return + } + + // Use the existing copyToProjectAssets IPC handler via renderer + const escaped1 = JSON.stringify(filePath) + const escaped2 = JSON.stringify(projectId) + const result = await rendererEval<{ success: boolean; path?: string; url?: string; error?: string }>( + `window.electronAPI.copyToProjectAssets(${escaped1}, ${escaped2})` + ) + jsonResponse(res, result?.success ? 200 : 500, result) + } catch (err) { + jsonResponse(res, 500, { error: String(err) }) + } +} + +// --------------------------------------------------------------------------- +// Request router +// --------------------------------------------------------------------------- + +async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + const method = req.method?.toUpperCase() || 'GET' + const url = req.url || '/' + + // CORS preflight + if (method === 'OPTIONS') { + jsonResponse(res, 204, '') + return + } + + // Route matching + const projectMatch = url.match(/^\/api\/projects\/([^/]+)$/) + + if (url === '/api/projects' && method === 'GET') { + await handleListProjects(res) + } else if (projectMatch && method === 'GET') { + await handleGetProject(res, projectMatch[1]) + } else if (projectMatch && method === 'PUT') { + await handleUpdateProject(req, res, projectMatch[1]) + } else if (url === '/api/export' && method === 'POST') { + await handleExport(req, res) + } else if (url === '/api/import-asset' && method === 'POST') { + await handleImportAsset(req, res) + } else { + jsonResponse(res, 404, { error: `Not found: ${method} ${url}` }) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function startProjectBridge(): void { + if (bridgeServer) return + + bridgeServer = http.createServer((req, res) => { + handleRequest(req, res).catch((err) => { + logger.error(`[ProjectBridge] Unhandled error: ${err}`) + jsonResponse(res, 500, { error: 'Internal server error' }) + }) + }) + + bridgeServer.listen(BRIDGE_PORT, '127.0.0.1', () => { + logger.info(`[ProjectBridge] Listening on http://127.0.0.1:${BRIDGE_PORT}`) + }) + + bridgeServer.on('error', (err) => { + logger.error(`[ProjectBridge] Server error: ${err}`) + }) +} + +export function stopProjectBridge(): void { + if (bridgeServer) { + bridgeServer.close() + bridgeServer = null + logger.info('[ProjectBridge] Stopped') + } +} diff --git a/ltx-desktop-mcp/SKILL.md b/ltx-desktop-mcp/SKILL.md new file mode 100644 index 00000000..f891a368 --- /dev/null +++ b/ltx-desktop-mcp/SKILL.md @@ -0,0 +1,686 @@ +--- +name: LTX-Desktop MCP 视频生成与编辑 +description: 使用 LTX-Desktop MCP Server 进行 AI 视频生成、时间轴编辑和视频导出的完整指南 +--- + +# LTX-Desktop MCP 视频生成与编辑 + +## 概述 + +LTX-Desktop 是一个基于 AI 的视频生成和编辑桌面应用。通过 MCP Server,AI 助手可以: +- **生成视频/图片**:调用后端 API(端口 8000) +- **编辑时间轴**:通过 Electron 桥(端口 8100)读写项目 JSON +- **导出成片**:调用 FFmpeg 合成导出 + +## 前置条件 + +1. LTX-Desktop 必须已启动(`pnpm dev`),确保后端(:8000)和桥(:8100)都在运行 +2. MCP Server 脚本路径:`/home/ve/下载/LTX-Desktop/ltx_mcp_server.py` + +--- + +## MCP 工具详细参数说明 + +### 一、视频生成工具 + +#### `generate_video` — 生成视频 + +从文本提示词生成视频,或基于输入图片/音频生成视频。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `prompt` | string | ✅ 必填 | — | 视频内容描述。不能为空,前后空格会被自动去除 | +| `resolution` | string | 选填 | `"512p"` | 生成分辨率 | +| `model` | string | 选填 | `"fast"` | 模型变体。`"fast"` = 快速蒸馏模型,`"pro"` = 高质量模型 | +| `duration` | string | 选填 | `"2"` | 视频时长(秒),传字符串 | +| `fps` | string | 选填 | `"24"` | 帧率,传字符串 | +| `camera_motion` | string | 选填 | `"none"` | 摄像机运动方式,见下表 | +| `negative_prompt` | string | 选填 | `""` | 负面提示词,描述要避免的内容 | +| `audio` | string | 选填 | `"false"` | 是否同时生成音频。`"true"` 或 `"false"` | +| `image_path` | string | 选填 | `null` | 输入图片的**本地绝对路径**,用于图生视频(image-to-video) | +| `audio_path` | string | 选填 | `null` | 输入音频的**本地绝对路径**,用于音生视频(audio-to-video) | +| `aspect_ratio` | string | 选填 | `"16:9"` | 画面比例。仅支持 `"16:9"` 或 `"9:16"` | + +**`camera_motion` 可选值:** + +| 值 | 说明 | +|---|---| +| `"none"` | 无运动(默认) | +| `"static"` | 完全静止镜头 | +| `"dolly_in"` | 推镜头(向前移动) | +| `"dolly_out"` | 拉镜头(向后移动) | +| `"dolly_left"` | 左移 | +| `"dolly_right"` | 右移 | +| `"jib_up"` | 镜头上升 | +| `"jib_down"` | 镜头下降 | +| `"focus_shift"` | 焦点转移 | + +**返回值:** +```json +{ "status": "complete", "video_path": "/absolute/path/to/output.mp4" } +``` + +--- + +#### `generate_image` — 生成图片 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `prompt` | string | ✅ 必填 | — | 图片内容描述。不能为空 | +| `width` | int | 选填 | `1024` | 图片宽度(像素) | +| `height` | int | 选填 | `1024` | 图片高度(像素) | +| `num_steps` | int | 选填 | `4` | 扩散步数,越大质量越高但越慢 | +| `num_images` | int | 选填 | `1` | 生成图片数量 | + +**返回值:** +```json +{ "status": "complete", "image_paths": ["/path/to/img1.png", "/path/to/img2.png"] } +``` + +--- + +#### `retake_video` — 视频局部重拍 + +对已有视频的某个时间段进行 AI 重新生成。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `video_path` | string | ✅ 必填 | — | 源视频的本地绝对路径 | +| `start_time` | float | ✅ 必填 | — | 重拍起始时间(秒),如 `1.5` | +| `duration` | float | ✅ 必填 | — | 重拍时长(秒),如 `2.0` | +| `prompt` | string | 选填 | `""` | 重拍内容的提示词,为空则保持相似风格 | +| `mode` | string | 选填 | `"replace_audio_and_video"` | 重拍模式,同时替换音视频 | + +**返回值:** +```json +{ "status": "complete", "video_path": "/path/to/retake_output.mp4" } +``` + +--- + +#### `suggest_gap_prompt` — AI 建议补帧提示词 + +当时间轴上两段片段之间有空隙时,AI 根据上下文建议一个合适的提示词来生成过渡内容。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `before_prompt` | string | 选填 | `""` | 空隙前面那段片段的提示词 | +| `after_prompt` | string | 选填 | `""` | 空隙后面那段片段的提示词 | +| `gap_duration` | float | 选填 | `5` | 空隙时长(秒) | +| `mode` | string | 选填 | `"t2v"` | 生成模式。`"t2v"` = 文生视频 | +| `before_frame` | string | 选填 | `null` | 空隙前最后一帧图片的路径 | +| `after_frame` | string | 选填 | `null` | 空隙后第一帧图片的路径 | +| `input_image` | string | 选填 | `null` | 输入参考图片路径 | + +**返回值:** +```json +{ "status": "success", "suggested_prompt": "A smooth camera pan transitioning from..." } +``` + +--- + +### 二、生成控制工具 + +#### `get_generation_progress` — 查询生成进度 + +无参数。实时查询当前视频/图片生成的进度。 + +**返回值:** +```json +{ + "status": "running", // "idle" | "running" | "complete" | "cancelled" | "error" + "phase": "denoising", // 当前阶段描述 + "progress": 45, // 总进度百分比 0-100 + "currentStep": 9, // 当前扩散步数 + "totalSteps": 20 // 总扩散步数 +} +``` + +--- + +#### `cancel_generation` — 取消生成 + +无参数。取消当前正在运行的生成任务。 + +**返回值:** +```json +{ "status": "cancelled", "id": "gen-xxx" } +``` + +--- + +### 三、模型管理工具 + +#### `get_health` — 健康检查 + +无参数。检查后端状态和 GPU 信息。 + +**返回值:** +```json +{ + "status": "ready", + "models_loaded": true, + "active_model": "fast", + "gpu_info": { "name": "NVIDIA GB202", "vram": 131072, "vramUsed": 45000 }, + "sage_attention": true, + "models_status": [ + { "id": "checkpoint", "name": "LTX Checkpoint", "loaded": true, "downloaded": true } + ] +} +``` + +--- + +#### `get_models_status` — 模型下载状态 + +无参数。查看所有模型文件的下载状态和大小。 + +**返回值:** +```json +{ + "all_downloaded": false, + "total_size_gb": 98.5, + "downloaded_size_gb": 45.2, + "models_path": "/home/ve/.local/share/LTXDesktop/models", + "has_api_key": true, + "models": [ + { + "id": "checkpoint", + "name": "LTX Video Checkpoint", + "downloaded": true, + "size": 12345678900, + "expected_size": 12345678900, + "required": true + } + ] +} +``` + +--- + +#### `download_models` — 触发模型下载 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `model_types` | list[string] | 选填 | `null`(全部下载) | 要下载的模型类型列表 | + +**`model_types` 可选值:** + +| 值 | 说明 | +|---|---| +| `"checkpoint"` | 主模型检查点(最大,~15GB) | +| `"upsampler"` | 超分辨率上采样器 | +| `"distilled_lora"` | 蒸馏 LoRA(用于 fast 模式加速) | +| `"ic_lora"` | 图像条件 LoRA | +| `"depth_processor"` | 深度估计处理器 | +| `"person_detector"` | 人体检测器 | +| `"pose_processor"` | 姿态估计处理器 | +| `"text_encoder"` | 文本编码器(~10GB,本地文本编码用) | +| `"zit"` | ZIT 图像修复模型 | + +--- + +### 四、项目管理工具(Electron 桥) + +#### `list_projects` — 列出所有项目 + +无参数。返回所有项目的摘要信息。 + +**返回值:** `Project[]` 数组(见下方 JSON 结构定义) + +--- + +#### `get_project` — 获取项目完整数据 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `project_id` | string | ✅ 必填 | 项目 ID,格式如 `"project-1710000000000-abc123def"` | + +**返回值:** 完整的 `Project` 对象 JSON(包含 assets、timelines、clips 等全部数据) + +--- + +#### `update_project` — 更新项目 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `project_id` | string | ✅ 必填 | 项目 ID | +| `project_json` | string | ✅ 必填 | **完整的项目 JSON 字符串**。先用 `get_project` 读取,修改后传回 | + +> ⚠️ **重要**:必须传完整的 Project JSON,不支持局部更新。先读后改再写! + +--- + +#### `export_video` — 导出视频 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `project_id` | string | ✅ 必填 | — | 要导出的项目 ID | +| `output_path` | string | ✅ 必填 | — | 输出文件的本地绝对路径(如 `/home/ve/output.mp4`) | +| `width` | int | 选填 | `1920` | 输出视频宽度(像素) | +| `height` | int | 选填 | `1080` | 输出视频高度(像素) | +| `fps` | int | 选填 | `24` | 输出帧率 | +| `codec` | string | 选填 | `"h264"` | 编码器。`"h264"` / `"prores"` / `"vp9"` | +| `quality` | int | 选填 | `80` | 输出质量。h264: CRF 值(0-51, 越小越好);prores: profile(0-3);vp9: 比特率(MB) | + +--- + +#### `import_asset` — 导入素材 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `project_id` | string | ✅ 必填 | 目标项目 ID | +| `file_path` | string | ✅ 必填 | 要导入的本地文件绝对路径(视频/图片/音频) | + +**返回值:** +```json +{ "success": true, "path": "/copied/path/to/file.mp4", "url": "file:///copied/path/to/file.mp4" } +``` + +--- + +## 项目 JSON 完整结构定义 + +### Project(项目) + +```json +{ + "id": "project-{timestamp}-{random9}", + "name": "项目名称", + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "thumbnail": "file:///path/to/thumb.png", + "assets": [ /* Asset[] */ ], + "timelines": [ /* Timeline[] */ ], + "activeTimelineId": "timeline-xxx" +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 唯一 ID,格式 `project-{timestamp}-{random9}` | +| `name` | string | 项目名称 | +| `createdAt` | number | 创建时间(毫秒时间戳) | +| `updatedAt` | number | 最后更新时间(毫秒时间戳) | +| `thumbnail` | string? | 项目缩略图 URL | +| `assets` | Asset[] | 项目中的所有素材 | +| `timelines` | Timeline[] | 时间轴列表(可多条) | +| `activeTimelineId` | string? | 当前激活的时间轴 ID | + +--- + +### Asset(素材) + +```json +{ + "id": "asset-{timestamp}-{random9}", + "type": "video", + "path": "/absolute/path/to/file.mp4", + "url": "file:///absolute/path/to/file.mp4", + "prompt": "生成时的提示词", + "resolution": "512p", + "duration": 5.0, + "createdAt": 1710000000000, + "thumbnail": "file:///path/to/thumb.png", + "favorite": false, + "takes": [], + "activeTakeIndex": 0 +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 唯一 ID | +| `type` | string | `"video"` / `"image"` / `"audio"` / `"adjustment"` | +| `path` | string | 文件在磁盘上的绝对路径 | +| `url` | string | `file://` 协议 URL | +| `prompt` | string? | AI 生成时的提示词 | +| `resolution` | string? | 生成分辨率 | +| `duration` | number? | 素材时长(秒),图片无此字段 | +| `createdAt` | number | 创建时间戳 | +| `thumbnail` | string? | 缩略图 URL | +| `favorite` | boolean | 是否收藏 | +| `takes` | AssetTake[] | 重拍/多版本历史 | +| `activeTakeIndex` | number? | 当前活跃版本索引 | + +--- + +### Timeline(时间轴) + +```json +{ + "id": "timeline-{timestamp}-{random9}", + "name": "Timeline 1", + "createdAt": 1710000000000, + "tracks": [ + { "id": "track-v1", "name": "V1", "kind": "video", "muted": false, "locked": false }, + { "id": "track-v2", "name": "V2", "kind": "video", "muted": false, "locked": false }, + { "id": "track-v3", "name": "V3", "kind": "video", "muted": false, "locked": false }, + { "id": "track-a1", "name": "A1", "kind": "audio", "muted": false, "locked": false }, + { "id": "track-a2", "name": "A2", "kind": "audio", "muted": false, "locked": false } + ], + "clips": [], + "subtitles": [] +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `tracks[].kind` | string | `"video"` 或 `"audio"` | +| `tracks[].muted` | boolean | 轨道是否静音 | +| `tracks[].locked` | boolean | 轨道是否锁定(锁定后不可编辑) | + +--- + +### TimelineClip(片段)— 核心数据结构 + +```json +{ + "id": "clip-{timestamp}-{random9}", + "assetId": "asset-xxx", + "type": "video", + "trackIndex": 0, + "startTime": 0, + "duration": 5.0, + "trimStart": 0, + "trimEnd": 0, + "speed": 1.0, + "reversed": false, + "muted": false, + "volume": 100, + "opacity": 100, + "flipH": false, + "flipV": false, + "linkedClipIds": [], + "transitionIn": { "type": "none", "duration": 0 }, + "transitionOut": { "type": "none", "duration": 0 }, + "colorCorrection": { + "brightness": 0, "contrast": 0, "saturation": 0, + "temperature": 0, "tint": 0, "exposure": 0, + "highlights": 0, "shadows": 0 + }, + "effects": [], + "asset": null, + "textStyle": null +} +``` + +**时间与播放控制:** + +| 字段 | 类型 | 范围 | 说明 | +|---|---|---|---| +| `type` | string | — | `"video"` / `"image"` / `"audio"` / `"adjustment"` / `"text"` | +| `trackIndex` | int | 0-4 | 轨道索引。**V1=0, V2=1, V3=2, A1=3, A2=4** | +| `startTime` | float | ≥0 | 在时间轴上的起始时间(秒) | +| `duration` | float | >0 | 播放时长(秒) | +| `trimStart` | float | ≥0 | 从素材头部裁掉的秒数 | +| `trimEnd` | float | ≥0 | 从素材尾部裁掉的秒数 | +| `speed` | float | >0 | 播放速度。`0.25`=四分之一速, `0.5`=慢放, `1.0`=正常, `2.0`=快进, `4.0`=四倍速 | +| `reversed` | boolean | — | `true` = 倒放 | + +**音频控制:** + +| 字段 | 类型 | 范围 | 说明 | +|---|---|---|---| +| `muted` | boolean | — | `true` = 静音 | +| `volume` | int | 0-100 | 音量百分比 | + +**视觉控制:** + +| 字段 | 类型 | 范围 | 说明 | +|---|---|---|---| +| `opacity` | int | 0-100 | 透明度。100=不透明, 0=完全透明 | +| `flipH` | boolean | — | 水平翻转 | +| `flipV` | boolean | — | 垂直翻转 | + +**关联:** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `assetId` | string | 引用的素材 ID | +| `linkedClipIds` | string[] | 关联的片段 ID 列表(音视频联动,移动一个另一个跟着) | +| `asset` | object? | 可设为 `null`,前端会根据 `assetId` 自动关联 | + +--- + +### Transition(转场) + +`transitionIn` 和 `transitionOut` 字段的结构: + +```json +{ "type": "dissolve", "duration": 0.5 } +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `type` | string | 转场类型,见下表 | +| `duration` | float | 转场时长(秒),通常 0.3-1.5 | + +**转场类型:** + +| 值 | 效果 | +|---|---| +| `"none"` | 无转场(硬切) | +| `"dissolve"` | 溶解/交叉淡化 | +| `"fade-to-black"` | 淡入/淡出黑色 | +| `"fade-to-white"` | 淡入/淡出白色 | +| `"wipe-left"` | 从右向左擦除 | +| `"wipe-right"` | 从左向右擦除 | +| `"wipe-up"` | 从下向上擦除 | +| `"wipe-down"` | 从上向下擦除 | + +--- + +### ColorCorrection(调色) + +所有值范围 **-100 到 100**,默认 **0**。 + +| 字段 | 说明 | +|---|---| +| `brightness` | 亮度。正值提亮,负值压暗 | +| `contrast` | 对比度。正值增强对比,负值降低 | +| `saturation` | 饱和度。正值增强色彩,负值减弱,-100=黑白 | +| `temperature` | 色温。正值偏暖(黄/橙),负值偏冷(蓝) | +| `tint` | 色调偏移。正值偏品红,负值偏绿 | +| `exposure` | 曝光。正值过曝,负值欠曝 | +| `highlights` | 高光。正值提高高光区域亮度,负值压低 | +| `shadows` | 阴影。正值提亮暗部,负值压暗 | + +--- + +### Effects(特效) + +`effects` 是一个数组,每个元素: + +```json +{ + "type": "blur", + "intensity": 50, + "enabled": true, + "mask": null +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `type` | string | 特效类型,见下表 | +| `intensity` | int | 强度 0-100 | +| `enabled` | boolean | 是否启用 | +| `mask` | object? | 可选遮罩(限定特效作用区域) | + +**特效类型:** + +| 值 | 效果 | +|---|---| +| `"blur"` | 高斯模糊 | +| `"sharpen"` | 锐化 | +| `"glow"` | 辉光/发光 | +| `"vignette"` | 暗角(四周变暗) | +| `"grain"` | 胶片颗粒噪点 | +| `"lut-cinematic"` | LUT 预设:电影感 | +| `"lut-vintage"` | LUT 预设:复古 | +| `"lut-bw"` | LUT 预设:黑白 | +| `"lut-cool"` | LUT 预设:冷色调 | +| `"lut-warm"` | LUT 预设:暖色调 | +| `"lut-muted"` | LUT 预设:低饱和 | +| `"lut-vivid"` | LUT 预设:高饱和鲜艳 | + +**遮罩(Mask)结构:** + +```json +{ + "shape": "rectangle", + "x": 50, "y": 50, + "width": 30, "height": 30, + "rotation": 0, + "feather": 10, + "inverted": false +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `shape` | string | `"rectangle"` 或 `"ellipse"` | +| `x`, `y` | float | 中心点位置(百分比 0-100) | +| `width`, `height` | float | 尺寸(百分比 0-100) | +| `rotation` | float | 旋转角度(度) | +| `feather` | float | 边缘羽化程度 0-100 | +| `inverted` | boolean | `true` = 反转遮罩(内部不受影响,外部受影响) | + +--- + +### SubtitleClip(字幕) + +```json +{ + "id": "sub-{timestamp}-{random9}", + "text": "字幕内容", + "startTime": 1.0, + "endTime": 4.0, + "trackIndex": 0, + "style": { + "fontSize": 32, + "fontFamily": "sans-serif", + "fontWeight": "normal", + "color": "#FFFFFF", + "backgroundColor": "transparent", + "position": "bottom", + "italic": false + } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `text` | string | 字幕文字内容 | +| `startTime` | float | 显示起始时间(秒) | +| `endTime` | float | 显示结束时间(秒) | +| `trackIndex` | int | 字幕轨道索引 | +| `style.fontSize` | int | 字号(像素) | +| `style.fontFamily` | string | 字体,如 `"sans-serif"`, `"Inter"`, `"Arial"` | +| `style.fontWeight` | string | 字重。`"normal"` / `"bold"` | +| `style.color` | string | 文字颜色(十六进制如 `"#FFFFFF"`) | +| `style.backgroundColor` | string | 背景色。`"transparent"` = 无背景 | +| `style.position` | string | 显示位置。`"top"` / `"center"` / `"bottom"` | +| `style.italic` | boolean | 是否斜体 | + +--- + +### TextStyle(文字叠加) + +用于 `type: "text"` 的片段,放在 `clip.textStyle` 字段中: + +```json +{ + "text": "标题文字", + "fontSize": 64, + "fontFamily": "Inter, Arial, sans-serif", + "fontWeight": "bold", + "color": "#FFFFFF", + "backgroundColor": "transparent", + "positionX": 50, + "positionY": 50, + "opacity": 100, + "strokeColor": "#000000", + "strokeWidth": 2, + "shadowColor": "rgba(0,0,0,0.5)", + "shadowBlur": 4, + "letterSpacing": 0, + "lineHeight": 1.2 +} +``` + +| 字段 | 类型 | 范围 | 说明 | +|---|---|---|---| +| `text` | string | — | 显示的文字内容 | +| `fontSize` | int | >0 | 字号(像素) | +| `fontFamily` | string | — | 字体族 | +| `fontWeight` | string | — | `"normal"` / `"bold"` / `"100"`-`"900"` | +| `color` | string | — | 文字颜色(十六进制) | +| `backgroundColor` | string | — | 背景色 | +| `positionX` | float | 0-100 | 水平位置(百分比,50=居中) | +| `positionY` | float | 0-100 | 垂直位置(百分比,50=居中) | +| `opacity` | int | 0-100 | 透明度 | +| `strokeColor` | string | — | 描边颜色 | +| `strokeWidth` | float | ≥0 | 描边宽度 | +| `shadowColor` | string | — | 阴影颜色 | +| `shadowBlur` | float | ≥0 | 阴影模糊半径 | +| `letterSpacing` | float | — | 字间距(像素) | +| `lineHeight` | float | >0 | 行高倍数 | + +--- + +## 常用操作配方 + +### 1. 生成视频并添加到时间轴 + +``` +步骤: +1. generate_video(prompt="一只猫在沙滩上奔跑") → {"video_path": "/path/to/video.mp4"} +2. get_project(project_id) → 拿到当前 project JSON +3. 构造 Asset 对象,插入 project.assets 数组头部 +4. 构造 TimelineClip 对象,设置 assetId 指向刚才的 Asset +5. 计算 startTime(= 时间轴上已有片段的末尾时间) +6. 将 clip 插入 project.timelines[activeTimeline].clips +7. update_project(project_id, 修改后的 JSON) +``` + +### 2. 音视频对齐 + +``` +让视频 clip 和音频 clip 的 startTime 设为相同值, +双方的 linkedClipIds 中互相引用对方的 id。 +``` + +### 3. 顺序排列多个片段 + +``` +clip1.startTime = 0 +clip2.startTime = clip1.startTime + clip1.duration +clip3.startTime = clip2.startTime + clip2.duration +``` + +### 4. 生成 ID 的规则 + +``` +timestamp = Date.now() 毫秒时间戳 +random9 = Math.random().toString(36).substr(2, 9) 的随机字符串 + +Project: "project-{timestamp}-{random9}" +Asset: "asset-{timestamp}-{random9}" +Timeline: "timeline-{timestamp}-{random9}" +Clip: "clip-{timestamp}-{random9}" +Subtitle: "sub-{timestamp}-{random9}" +``` + +--- + +## 注意事项 + +1. **先读后改**:修改项目前务必先 `get_project` 读取最新状态 +2. **ID 不可变**:不要修改已有对象的 `id` 字段 +3. **asset 字段**:clip 中的 `asset` 字段设为 `null` 即可,前端根据 `assetId` 自动关联 +4. **文件路径**:所有路径必须是绝对路径,URL 使用 `file://` 协议 +5. **GPU 要求**:视频/图片生成需要 NVIDIA GPU(≥32GB VRAM)+ 已下载模型 +6. **FFmpeg 要求**:视频导出依赖系统 FFmpeg +7. **单次生成**:后端同一时刻只能运行一个生成任务,需等上一个完成或取消后才能开始下一个 diff --git a/ltx_mcp_server.py b/ltx_mcp_server.py new file mode 100644 index 00000000..2419484e --- /dev/null +++ b/ltx_mcp_server.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""LTX-Desktop MCP Server. + +Exposes LTX-Desktop's video generation backend (FastAPI on :8000) and +Electron project bridge (HTTP on :8100) as MCP tools so AI assistants +can automate video generation and timeline editing. + +Usage: + 1. Start LTX-Desktop normally (pnpm dev) + 2. Run this server: python ltx_mcp_server.py + 3. Register in your AI assistant's MCP configuration. +""" +from __future__ import annotations + +import os +os.environ["PYTHONUNBUFFERED"] = "1" + +import json +import logging +from typing import Any + +import httpx +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +LTX_BACKEND = os.getenv("LTX_BACKEND_URL", "http://127.0.0.1:8000") +LTX_BRIDGE = os.getenv("LTX_BRIDGE_URL", "http://127.0.0.1:8100") +REQUEST_TIMEOUT = float(os.getenv("LTX_TIMEOUT", "600")) # seconds + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("ltx-mcp") + +mcp = FastMCP( + "ltx-desktop", + description="MCP server for LTX-Desktop: AI video generation + timeline editing", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +async def _backend_get(path: str, params: dict | None = None) -> dict[str, Any]: + """GET request to the LTX FastAPI backend.""" + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.get(f"{LTX_BACKEND}{path}", params=params) + resp.raise_for_status() + return resp.json() + + +async def _backend_post(path: str, body: dict | None = None) -> dict[str, Any]: + """POST request to the LTX FastAPI backend.""" + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.post(f"{LTX_BACKEND}{path}", json=body or {}) + resp.raise_for_status() + return resp.json() + + +async def _bridge_get(path: str) -> Any: + """GET request to the Electron project bridge.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{LTX_BRIDGE}{path}") + resp.raise_for_status() + return resp.json() + + +async def _bridge_post(path: str, body: dict | None = None) -> Any: + """POST / PUT request to the Electron project bridge.""" + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(f"{LTX_BRIDGE}{path}", json=body or {}) + resp.raise_for_status() + return resp.json() + + +async def _bridge_put(path: str, body: Any = None) -> Any: + """PUT request to the Electron project bridge.""" + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.put(f"{LTX_BRIDGE}{path}", json=body) + resp.raise_for_status() + return resp.json() + + +# =================================================================== +# BACKEND API TOOLS (connect to FastAPI on :8000) +# =================================================================== + +@mcp.tool() +async def get_health() -> str: + """Check LTX-Desktop backend health: GPU status, loaded models, etc.""" + result = await _backend_get("/health") + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def get_models_status() -> str: + """Get the download status of all required model files (checkpoint, upsampler, text encoder, etc.).""" + result = await _backend_get("/api/models/status") + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def download_models(model_types: list[str] | None = None) -> str: + """Start downloading required model files. + + Args: + model_types: Optional list of model types to download. + Valid values: checkpoint, upsampler, distilled_lora, ic_lora, + depth_processor, person_detector, pose_processor, text_encoder, zit. + If empty, downloads all required models. + """ + body: dict[str, Any] = {} + if model_types: + body["modelTypes"] = model_types + result = await _backend_post("/api/models/download", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def generate_video( + prompt: str, + resolution: str = "512p", + model: str = "fast", + duration: str = "2", + fps: str = "24", + camera_motion: str = "none", + negative_prompt: str = "", + audio: str = "false", + image_path: str | None = None, + audio_path: str | None = None, + aspect_ratio: str = "16:9", +) -> str: + """Generate a video from a text prompt (text-to-video), an image (image-to-video), or audio (audio-to-video). + + Args: + prompt: The text description of the video to generate. Required. + resolution: Video resolution. Default "512p". + model: Model variant. "fast" (default) or "pro". + duration: Video duration in seconds. Default "2". + fps: Frames per second. Default "24". + camera_motion: Camera movement type. Options: none, dolly_in, dolly_out, + dolly_left, dolly_right, jib_up, jib_down, static, focus_shift. + negative_prompt: Things to avoid in the generated video. + audio: Whether to generate audio. "true" or "false" (default). + image_path: Local file path of an input image for image-to-video generation. + audio_path: Local file path of an input audio for audio-to-video generation. + aspect_ratio: "16:9" (default) or "9:16". + """ + body: dict[str, Any] = { + "prompt": prompt, + "resolution": resolution, + "model": model, + "duration": duration, + "fps": fps, + "cameraMotion": camera_motion, + "negativePrompt": negative_prompt, + "audio": audio, + "aspectRatio": aspect_ratio, + } + if image_path: + body["imagePath"] = image_path + if audio_path: + body["audioPath"] = audio_path + + result = await _backend_post("/api/generate", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def generate_image( + prompt: str, + width: int = 1024, + height: int = 1024, + num_steps: int = 4, + num_images: int = 1, +) -> str: + """Generate images from a text prompt. + + Args: + prompt: The text description of the image to generate. Required. + width: Image width in pixels. Default 1024. + height: Image height in pixels. Default 1024. + num_steps: Number of diffusion steps. Default 4. + num_images: Number of images to generate. Default 1. + """ + body = { + "prompt": prompt, + "width": width, + "height": height, + "numSteps": num_steps, + "numImages": num_images, + } + result = await _backend_post("/api/generate-image", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def retake_video( + video_path: str, + start_time: float, + duration: float, + prompt: str = "", + mode: str = "replace_audio_and_video", +) -> str: + """Re-generate a portion of an existing video (retake / inpainting). + + Args: + video_path: Local path to the source video file. + start_time: Start time in seconds for the retake region. + duration: Duration in seconds for the retake region. + prompt: Optional prompt to guide the retake. + mode: Retake mode. Default "replace_audio_and_video". + """ + body = { + "video_path": video_path, + "start_time": start_time, + "duration": duration, + "prompt": prompt, + "mode": mode, + } + result = await _backend_post("/api/retake", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def suggest_gap_prompt( + before_prompt: str = "", + after_prompt: str = "", + gap_duration: float = 5, + mode: str = "t2v", + before_frame: str | None = None, + after_frame: str | None = None, + input_image: str | None = None, +) -> str: + """Ask the AI to suggest a prompt for filling a gap in the timeline. + + Args: + before_prompt: Prompt of the clip before the gap. + after_prompt: Prompt of the clip after the gap. + gap_duration: Duration of the gap in seconds. + mode: Generation mode. "t2v" (text-to-video) or others. + before_frame: Optional path to the last frame image before the gap. + after_frame: Optional path to the first frame image after the gap. + input_image: Optional input image path. + """ + body: dict[str, Any] = { + "beforePrompt": before_prompt, + "afterPrompt": after_prompt, + "gapDuration": gap_duration, + "mode": mode, + } + if before_frame: + body["beforeFrame"] = before_frame + if after_frame: + body["afterFrame"] = after_frame + if input_image: + body["inputImage"] = input_image + + result = await _backend_post("/api/suggest-gap-prompt", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def get_generation_progress() -> str: + """Get the current video generation progress (status, phase, step count).""" + result = await _backend_get("/api/generation/progress") + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def cancel_generation() -> str: + """Cancel the currently running video generation.""" + result = await _backend_post("/api/generate/cancel") + return json.dumps(result, ensure_ascii=False, indent=2) + + +# =================================================================== +# ELECTRON BRIDGE TOOLS (connect to project bridge on :8100) +# =================================================================== + +@mcp.tool() +async def list_projects() -> str: + """List all projects in LTX-Desktop. Each project contains assets and timelines. + + Returns a JSON array of project objects with their IDs, names, and metadata. + """ + result = await _bridge_get("/api/projects") + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def get_project(project_id: str) -> str: + """Get a specific project's full data including assets, timelines, clips, and settings. + + Args: + project_id: The project's unique ID (e.g. "project-1710000000000-abc123def"). + """ + result = await _bridge_get(f"/api/projects/{project_id}") + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def update_project(project_id: str, project_json: str) -> str: + """Update a project's data (assets, timelines, clips, etc.). + + This tool edits the entire project JSON. Use get_project first to read + the current state, modify the needed fields, then send back the full + project JSON. + + Args: + project_id: The project's unique ID. + project_json: The complete project JSON string. Must be valid JSON + conforming to the Project type schema. + """ + project_data = json.loads(project_json) + result = await _bridge_put(f"/api/projects/{project_id}", project_data) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def export_video( + project_id: str, + output_path: str, + width: int = 1920, + height: int = 1080, + fps: int = 24, + codec: str = "h264", + quality: int = 80, +) -> str: + """Export the current timeline of a project as a video file using FFmpeg. + + Args: + project_id: The project's ID to export from. + output_path: Local filesystem path for the output video (e.g. "/home/user/output.mp4"). + width: Output video width. Default 1920. + height: Output video height. Default 1080. + fps: Output frame rate. Default 24. + codec: Video codec. Default "h264". + quality: Output quality (0-100). Default 80. + """ + body = { + "projectId": project_id, + "outputPath": output_path, + "width": width, + "height": height, + "fps": fps, + "codec": codec, + "quality": quality, + } + result = await _bridge_post("/api/export", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +@mcp.tool() +async def import_asset( + project_id: str, + file_path: str, +) -> str: + """Import a local file (video, image, or audio) into a project's asset library. + + The file will be copied into the project's assets directory and be + available for use on the timeline. + + Args: + project_id: The project's ID to import into. + file_path: Absolute local path to the file to import. + """ + body = { + "projectId": project_id, + "filePath": file_path, + } + result = await _bridge_post("/api/import-asset", body) + return json.dumps(result, ensure_ascii=False, indent=2) + + +# =================================================================== +# Entry point +# =================================================================== +if __name__ == "__main__": + logger.info("Starting LTX-Desktop MCP Server...") + logger.info(" Backend URL: %s", LTX_BACKEND) + logger.info(" Bridge URL: %s", LTX_BRIDGE) + mcp.run() diff --git a/tsconfig.node.json b/tsconfig.node.json index d923a04d..951f9a10 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,13 +2,15 @@ "compilerOptions": { "composite": true, "skipLibCheck": true, + "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true, "outDir": "dist-electron", "rootDir": "electron", - "esModuleInterop": true + "esModuleInterop": true, + "types": ["node"] }, "include": ["electron/**/*.ts", "vite.config.ts"] }