cerevox
Version:
TypeScript SDK for browser automation and secure command execution in highly available and scalable micro computer environments
1,273 lines (1,262 loc) • 193 kB
JavaScript
#!/usr/bin/env node
"use strict";
/*
* MCP Server
* CallTool 有些工具要求的时间较长,可能会60秒超时,callTool 的时候最好设置一下 timeout
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.run = run;
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const zod_1 = require("zod");
const index_1 = __importDefault(require("../../index"));
const constants_1 = require("../../utils/constants");
const videokit_1 = require("../../utils/videokit");
const promises_1 = require("node:fs/promises");
const node_path_1 = __importStar(require("node:path"));
const doubao_voices_full_1 = require("./helper/doubao_voices_full");
const node_fs_1 = require("node:fs");
const coze_1 = require("../../utils/coze");
const mp3_duration_1 = __importDefault(require("mp3-duration"));
const image_size_1 = __importDefault(require("image-size"));
function createErrorResponse(error, operation, details) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[${operation}] Error:`, error);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
error: errorMessage,
operation,
timestamp: new Date().toISOString(),
details,
}),
},
],
};
}
// Session 状态检查
async function validateSession(operation) {
if (closeSessionTimerId) {
clearTimeout(closeSessionTimerId);
closeSessionTimerId = null;
}
if (!session || !(await session.isRunning())) {
session = null;
throw new Error(`Session not initialized. Please call 'project-open' first before using ${operation}.`);
}
const projectRulesFile = (0, node_path_1.resolve)(projectLocalDir, '.trae', 'rules', `project_rules.md`);
if (!(0, node_fs_1.existsSync)(projectRulesFile)) {
throw new Error(`Project rules file not found: ${projectRulesFile}. Please call 'retrieve-rules-context' first.`);
}
return session;
}
// 文件名验证
function validateFileName(fileName) {
if (!fileName || fileName.trim() === '') {
throw new Error('File name cannot be empty');
}
if (fileName.includes('..') ||
fileName.includes('/') ||
fileName.includes('\\')) {
throw new Error('Invalid file name: contains illegal characters');
}
return fileName.trim();
}
function getMaterialUri(session, fileName) {
return session.sandbox.getUrl(`/zerocut/${session.terminal.id}/materials/${(0, node_path_1.basename)(fileName)}`);
}
/* Configuration
{
"mcpServers": {
"cerevox-zerocut": {
"command": "npx",
"args": ["-y", "cerevox-zerocut"],
"env": {
"CEREVOX_API_KEY": "ck-**************"
}
},
}
}
*/
async function initProject(session) {
const terminal = session.terminal;
const workDir = `/home/user/cerevox-zerocut/projects/${terminal.id}`;
await session.files.mkdir(workDir);
await (await terminal.run(`cd ${workDir}`)).end();
// 创建素材目录和成品目录 materials、output
await (await terminal.run('mkdir materials output')).end();
// 文件包括(大部分不需要此时创建)
// storyboard.json 故事版
// draft_content.json 草稿内容,Agent在创作过程中,会根据项目规范,自动生成和修改该文件
return workDir;
}
async function saveMaterial(session, url, saveToFileName) {
const terminal = session.terminal;
const saveToPath = `/home/user/cerevox-zerocut/projects/${terminal.id}/materials/${saveToFileName}`;
const saveLocalPath = (0, node_path_1.resolve)(projectLocalDir, 'materials', saveToFileName);
// 先下载到本地,再上传 sandbox,比直接 sandbox 更好,也以免下载超时
// 通过 fetch 下载到本地
const res = await fetch(url);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await (0, promises_1.writeFile)(saveLocalPath, buffer);
// 保存后上传,这样更靠谱
const files = session.files;
await files.upload(saveLocalPath, saveToPath);
return saveToPath;
}
async function runFfmpeg(session, compiled) {
const files = session.files;
if (compiled.extraFiles?.length) {
for (const f of compiled.extraFiles) {
const writePath = f.path.replace(/[\\]+/g, '/'); // 因为有可能在不同操作系统下远程运行,所以需要 normalize
await files.write(writePath, f.content);
// await files.write(f.path, f.content);
}
}
const terminal = session.terminal;
const response = await terminal.run(compiled.cmd);
response.stdout.pipe(process.stdout);
response.stderr.pipe(process.stderr);
return response.json();
}
async function sendProgress(context, progress, total, message) {
const token = context._meta?.progressToken;
if (!token)
return; // 客户端没要进度就不用发
await context.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress, total, message },
});
}
async function updateMediaLogs(session, fileName, result, mediaType = 'video') {
try {
const mediaLogsPath = node_path_1.default.resolve(projectLocalDir, 'media_logs.json');
let mediaLogs = [];
// 尝试读取现有文件
try {
const existingContent = await (0, promises_1.readFile)(mediaLogsPath, 'utf-8');
mediaLogs = JSON.parse(existingContent);
}
catch (error) {
// 文件不存在或格式错误,使用空数组
mediaLogs = [];
}
// 检查是否已存在相同文件名的记录
const existingIndex = mediaLogs.findIndex(item => item.fileName === fileName);
const logEntry = {
fileName,
mediaType,
timestamp: new Date().toISOString(),
result,
};
if (existingIndex >= 0) {
// 更新现有记录
mediaLogs[existingIndex] = logEntry;
}
else {
// 添加新记录
mediaLogs.push(logEntry);
}
// 写入文件
await (0, promises_1.writeFile)(mediaLogsPath, JSON.stringify(mediaLogs, null, 2));
console.log(`Updated media_logs.json: ${fileName} -> ${JSON.stringify(result)} (${mediaType})`);
}
catch (error) {
console.warn(`Failed to update media_logs.json for ${fileName}:`, error);
}
}
async function filterMaterialsForUpload(materials, projectLocalDir) {
const filesToUpload = [];
const skippedFiles = [];
// 检查是否存在 media_logs.json
const mediaLogsPath = node_path_1.default.resolve(projectLocalDir, 'media_logs.json');
let mediaLogs = [];
try {
const existingContent = await (0, promises_1.readFile)(mediaLogsPath, 'utf-8');
mediaLogs = JSON.parse(existingContent);
}
catch (error) {
// 文件不存在或格式错误,使用空数组
mediaLogs = [];
}
// 检查文件名是否符合 scXX_ 前缀规则
const isSceneFile = (fileName) => {
const baseName = (0, node_path_1.basename)(fileName);
return /^sc\d+_/.test(baseName);
};
// 获取文件的前缀和扩展名
const getFilePrefix = (fileName) => {
const baseName = (0, node_path_1.basename)(fileName);
const match = baseName.match(/^(sc\d+_)/);
return match ? match[1] : '';
};
const getFileExtension = (fileName) => {
return node_path_1.default.extname(fileName);
};
// 分组处理文件
const sceneFiles = [];
const regularFiles = [];
for (const material of materials) {
// 跳过 .DS_Store 文件
if ((0, node_path_1.basename)(material) === '.DS_Store') {
skippedFiles.push(material);
continue;
}
// 跳过 .local.xxx 文件(文件名中包含 .local.)
if ((0, node_path_1.basename)(material).includes('.local.')) {
skippedFiles.push(material);
continue;
}
if (isSceneFile(material)) {
sceneFiles.push(material);
}
else {
regularFiles.push(material);
}
}
// 处理场景文件(scXX_ 前缀)
if (sceneFiles.length > 0 && mediaLogs.length > 0) {
// 按前缀和扩展名分组
const groupedFiles = new Map();
for (const file of sceneFiles) {
const prefix = getFilePrefix(file);
const ext = getFileExtension(file);
const key = `${prefix}${ext}`;
if (!groupedFiles.has(key)) {
groupedFiles.set(key, []);
}
groupedFiles.get(key).push(file);
}
// 对每个组,只保留最新的文件
for (const [key, files] of groupedFiles) {
if (files.length === 1) {
filesToUpload.push(files[0]);
}
else {
// 找到在 media_logs 中有记录的文件
const filesWithLogs = [];
const filesWithoutLogs = [];
for (const file of files) {
const fileName = (0, node_path_1.basename)(file);
const logEntry = mediaLogs.find(log => log.fileName === fileName);
if (logEntry) {
filesWithLogs.push({ file, timestamp: logEntry.timestamp });
}
else {
filesWithoutLogs.push(file);
}
}
// 如果有文件在 media_logs 中没有记录,这些文件必须上传
filesToUpload.push(...filesWithoutLogs);
// 对于有记录的文件,只保留最新的一个
if (filesWithLogs.length > 0) {
filesWithLogs.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
const latestFileWithLog = filesWithLogs[0].file;
filesToUpload.push(latestFileWithLog);
// 其他有记录的文件标记为跳过
for (let i = 1; i < filesWithLogs.length; i++) {
skippedFiles.push(filesWithLogs[i].file);
}
}
}
}
}
else {
// 如果没有 media_logs.json 或没有场景文件,全部上传
filesToUpload.push(...sceneFiles);
}
// 处理普通文件(全量上传)
filesToUpload.push(...regularFiles);
return { filesToUpload, skippedFiles };
}
async function listFiles(dir) {
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
return entries.filter(e => e.isFile()).map(e => (0, node_path_1.resolve)(dir, e.name));
}
// Create an MCP server
const server = new mcp_js_1.McpServer({
name: 'Cerevox Server',
version: constants_1.VERSION,
});
const cerevox = new index_1.default({
apiKey: process.env.CEREVOX_API_KEY || '',
logLevel: 'error',
});
let session = null;
let projectLocalDir = process.env.ZEROCUT_PROJECT_CWD || process.cwd() || '.';
let checkStoryboardFlag = false;
let checkAudioVideoDurationFlag = false;
let checkStoryboardSubtitlesFlag = false;
let closeSessionTimerId = null;
// 注册 ZeroCut 指导规范 Prompt
server.registerPrompt('zerocut-guideline', {
title: 'ZeroCut 短视频创作指导规范',
description: '专业的短视频创作 Agent 指导规范,包含完整的工作流程、工具说明和质量建议',
}, async () => {
try {
const promptPath = (0, node_path_1.resolve)(__dirname, './prompts/zerocut-core.md');
const promptContent = await (0, promises_1.readFile)(promptPath, 'utf-8');
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: promptContent,
},
},
],
};
}
catch (error) {
console.error('Failed to load zerocut-guideline prompt:', error);
throw new Error(`Failed to load zerocut-guideline prompt: ${error}`);
}
});
server.registerTool('retrieve-rules-context', {
title: 'ZeroCut Basic Rules',
description: `ZeroCut-${constants_1.VERSION} Basic Rules`,
inputSchema: {
purpose: zod_1.z
.enum([
'general-video',
'music-video',
'stage-play',
'story-telling',
'creative-ad',
'expert',
'material-creation',
'freeform',
'custom',
])
.default('general-video')
.describe(`The purpose of the rules context to retrieve.
- general-video 创建通用视频
- music-video 创建音乐视频
- stage-play 创建舞台播放视频
- story-telling 创建故事讲述视频
- creative-ad 创建创意广告视频
- expert 以专家模式创建视频,必须用户主动要求才触发
- material-creation 素材创作模式,必须用户主动要求才触发
- freeform 自由创作模式,必须用户主动要求才触发
- custom 自定义模式`),
},
}, async ({ purpose }) => {
const projectRulesFile = (0, node_path_1.resolve)(projectLocalDir, '.trae', 'rules', `project_rules.md`);
const customRulesFile = (0, node_path_1.resolve)(projectLocalDir, '.trae', 'rules', `custom_rules.md`);
let promptContent = '';
if ((0, node_fs_1.existsSync)(projectRulesFile)) {
promptContent = await (0, promises_1.readFile)(projectRulesFile, 'utf-8');
if ((0, node_fs_1.existsSync)(customRulesFile)) {
promptContent +=
'\n\n---\n\n' + (await (0, promises_1.readFile)(customRulesFile, 'utf-8'));
}
}
else {
// 当 projectRulesFile 不存在时,设置 checkStoryboardFlag 为 false
checkStoryboardFlag = false;
// 当 projectRulesFile 不存在时,设置 checkAudioVideoDurationFlag 为 false
checkAudioVideoDurationFlag = false;
// 当 projectRulesFile 不存在时,设置 checkStoryboardSubtitlesFlag 为 false
checkStoryboardSubtitlesFlag = false;
if (purpose !== 'general-video' &&
purpose !== 'music-video' &&
purpose !== 'stage-play' &&
purpose !== 'story-telling' &&
purpose !== 'creative-ad' &&
purpose !== 'expert' &&
purpose !== 'material-creation' &&
purpose !== 'freeform') {
return createErrorResponse(`Project rules file not found: ${projectRulesFile}`, 'retrieve-rules-context');
}
}
try {
if (!promptContent) {
const promptPath = (0, node_path_1.resolve)(__dirname, `./prompts/rules/${purpose}.md`);
promptContent = await (0, promises_1.readFile)(promptPath, 'utf-8');
// 确保目录存在
await (0, promises_1.mkdir)((0, node_path_1.dirname)(projectRulesFile), { recursive: true });
await (0, promises_1.writeFile)(projectRulesFile, promptContent);
}
// 如果 purpose 为 freeform,复制 skills 和 knowledge 目录
if (purpose === 'freeform') {
// 递归复制目录的通用函数
const copyDirectory = async (src, dest) => {
const entries = await (0, promises_1.readdir)(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = (0, node_path_1.resolve)(src, entry.name);
const destPath = (0, node_path_1.resolve)(dest, entry.name);
if (entry.isDirectory()) {
await (0, promises_1.mkdir)(destPath, { recursive: true });
await copyDirectory(srcPath, destPath);
}
else {
const content = await (0, promises_1.readFile)(srcPath, 'utf-8');
await (0, promises_1.writeFile)(destPath, content);
}
}
};
// 复制 skills 目录
const sourceSkillsDir = (0, node_path_1.resolve)(__dirname, './prompts/skills');
const targetSkillsDir = (0, node_path_1.resolve)(projectLocalDir, '.trae', 'skills');
try {
await (0, promises_1.mkdir)(targetSkillsDir, { recursive: true });
await copyDirectory(sourceSkillsDir, targetSkillsDir);
console.log(`Skills directory copied to ${targetSkillsDir}`);
}
catch (skillsError) {
console.warn(`Failed to copy skills directory: ${skillsError}`);
}
// 复制 knowledge 目录
const sourceKnowledgeDir = (0, node_path_1.resolve)(__dirname, './prompts/knowledge');
const targetKnowledgeDir = (0, node_path_1.resolve)(projectLocalDir, '.trae', 'knowledge');
try {
await (0, promises_1.mkdir)(targetKnowledgeDir, { recursive: true });
await copyDirectory(sourceKnowledgeDir, targetKnowledgeDir);
console.log(`Knowledge directory copied to ${targetKnowledgeDir}`);
}
catch (knowledgeError) {
console.warn(`Failed to copy knowledge directory: ${knowledgeError}`);
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
type: 'project_rules',
content: promptContent,
projectRulesFile,
}),
},
],
};
}
catch (error) {
console.error(`Failed to load rules context prompt for ${purpose}:`, error);
return createErrorResponse(`Failed to load rules context prompt for ${purpose}: ${error}`, 'retrieve-rules-context');
}
});
server.registerTool('project-open', {
title: 'Open Project',
description: 'Launch a new Cerevox session with a Chromium browser instance and open a new project context. Supports smart file filtering to optimize upload performance.',
inputSchema: {
localDir: zod_1.z
.string()
.optional()
.default('.')
.describe('The path of the file to upload.'),
tosFiles: zod_1.z
.array(zod_1.z.string())
.optional()
.default([])
.describe('对象存储系统中的持久化文件,不通过本地直接下载到项目目录,可选参数'),
uploadAllFiles: zod_1.z
.boolean()
.optional()
.default(false)
.describe('Whether to upload all files without filtering. If true, skips the smart filtering logic.'),
},
}, async ({ localDir, uploadAllFiles, tosFiles }, context) => {
try {
if (closeSessionTimerId) {
clearTimeout(closeSessionTimerId);
closeSessionTimerId = null;
}
// 检查是否已有活跃session
if (session) {
console.warn('Session already exists, closing previous session');
// try {
// await session.close();
// } catch (closeError) {
// console.warn('Failed to close previous session:', closeError);
// }
const result = {
success: true,
sessionId: session.id,
workDir: `/home/user/cerevox-zerocut/projects/${session.terminal.id}`,
projectLocalDir,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
const apiKey = process.env.CEREVOX_API_KEY;
// 验证API密钥
if (!apiKey) {
throw new Error('CEREVOX_API_KEY environment variable is required');
}
if (!apiKey.startsWith('ck-') || apiKey.length < 102) {
throw new Error('不正确的apiKey,请登录ZeroCut后台(https://workspace.zerocut.cn/apikey)获取apiKey,进行配置');
}
console.log('Launching new Cerevox session...');
session = await cerevox.launch({
timeout: 60,
});
if (!session) {
throw new Error('Failed to create Cerevox session');
}
console.log('Initializing project...');
const workDir = await initProject(session);
projectLocalDir = (0, node_path_1.resolve)(process.env.ZEROCUT_PROJECT_CWD || process.cwd(), localDir || '.');
const syncDir = (0, node_path_1.resolve)(projectLocalDir, 'materials');
try {
await (0, promises_1.mkdir)(syncDir, { recursive: true });
}
catch (mkdirError) {
console.warn('Failed to create materials directory:', mkdirError);
// 继续执行,可能目录已存在
}
let materials = [];
// 文件过滤逻辑
let filesToUpload = [];
let skippedFiles = [];
if (localDir) {
try {
materials = await listFiles(syncDir);
}
catch (listError) {
console.warn('Failed to list materials:', listError);
materials = [];
}
if (uploadAllFiles) {
// 如果 uploadAllFiles 为 true,跳过智能过滤,上传所有文件
filesToUpload = materials;
skippedFiles = [];
}
else {
// 智能文件过滤逻辑
const filterResult = await filterMaterialsForUpload(materials, projectLocalDir);
filesToUpload = filterResult.filesToUpload;
skippedFiles = filterResult.skippedFiles;
}
}
const files = session.files;
let progress = 0;
const uploadErrors = [];
const totalFiles = filesToUpload.length + tosFiles.length;
for (const material of filesToUpload) {
try {
await files.upload(material, `${workDir}/materials/${(0, node_path_1.basename)(material)}`);
await sendProgress(context, ++progress, totalFiles, material);
}
catch (uploadError) {
const errorMsg = `Failed to upload ${material}: ${uploadError}`;
console.error(errorMsg);
uploadErrors.push(errorMsg);
}
}
for (const tosFile of tosFiles) {
try {
const url = new URL(tosFile);
await session.terminal.run(`wget -O ${workDir}/materials/${(0, node_path_1.basename)(url.pathname)} ${tosFile}`);
await sendProgress(context, ++progress, totalFiles, tosFile);
}
catch (uploadError) {
const errorMsg = `Failed to upload ${tosFile}: ${uploadError}`;
console.error(errorMsg);
uploadErrors.push(errorMsg);
}
}
const result = {
success: true,
nextActionSuggest: '检查规则上下文是否已召回,若未召回,调用 retrieve_rules 工具召回规则上下文',
sessionId: session.id,
workDir,
projectLocalDir,
materials,
uploadedFiles: filesToUpload.map(file => (0, node_path_1.basename)(file)),
skippedFiles: skippedFiles.map(file => (0, node_path_1.basename)(file)),
uploadErrors: uploadErrors.length > 0 ? uploadErrors : undefined,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
catch (error) {
// 不自动关闭session,让agent根据异常信息自行处理
return createErrorResponse(error, 'project-open');
}
});
server.registerTool('project-close', {
title: 'Close Project',
description: 'Close the current Cerevox session and release all resources.',
inputSchema: {
inMinutes: zod_1.z
.number()
.int()
.min(0)
.max(20)
.default(5)
.describe('Close the session after the specified number of minutes. Default is 5 minutes. 如果用户要求立即关闭会话,请将该参数设置为0!'),
},
}, async ({ inMinutes }) => {
try {
if (session) {
closeSessionTimerId = setTimeout(() => {
console.log('Closing Cerevox session...');
session?.close();
session = null;
}, inMinutes * 60 * 1000);
console.log('Session closed successfully');
}
else {
console.warn('No active session to close');
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Project closed successfully.',
timestamp: new Date().toISOString(),
}),
},
],
};
}
catch (error) {
// 即使关闭失败,也要清理session引用
session = null;
return createErrorResponse(error, 'project-close');
}
});
// 列出项目下的所有文件
server.registerTool('list-project-files', {
title: 'List Project Files',
description: 'List all files in the materials directory.',
inputSchema: {},
}, async () => {
try {
// 验证session状态
const currentSession = await validateSession('list-project-files');
console.log('Listing project files...');
const terminal = currentSession.terminal;
if (!terminal) {
throw new Error('Terminal not available in current session');
}
let cwd;
try {
cwd = await terminal.getCwd();
}
catch (cwdError) {
console.error('Failed to get current working directory:', cwdError);
throw new Error('Failed to get current working directory');
}
console.log(`Current working directory: ${cwd}`);
// 安全地列出各目录文件,失败时返回空数组
const listFilesWithFallback = async (path, dirName) => {
try {
const files = await currentSession.files.listFiles(path);
console.log(`Found ${files?.length || 0} files in ${dirName}`);
return files || [];
}
catch (error) {
console.warn(`Failed to list files in ${dirName} (${path}):`, error);
return [];
}
};
const [rootFiles, materialsFiles, outputFiles] = await Promise.all([
listFilesWithFallback(cwd, 'root'),
listFilesWithFallback(`${cwd}/materials`, 'materials'),
listFilesWithFallback(`${cwd}/output`, 'output'),
]);
const result = {
success: true,
cwd,
root: rootFiles,
materials: materialsFiles,
output: outputFiles,
totalFiles: rootFiles.length + materialsFiles.length + outputFiles.length,
timestamp: new Date().toISOString(),
};
console.log(`Total files found: ${result.totalFiles}`);
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
catch (error) {
return createErrorResponse(error, 'list-project-files');
}
});
server.registerTool('generate-character-image', {
title: 'Generate Character Image',
description: 'Generate a turnaround image or portrait for any character.',
inputSchema: {
type: zod_1.z.enum(['banana', 'seedream']).optional().default('banana'),
name: zod_1.z.string().describe('The name of the character.'),
gender: zod_1.z
.enum(['male', 'female'])
.describe('The gender of the character.'),
age: zod_1.z
.enum(['儿童', '少年', '青年', '中年', '老年'])
.describe('The age of the character.'),
appearance: zod_1.z.string().describe('The appearance of the character.'),
clothing: zod_1.z.string().describe('The clothing of the character.'),
personality: zod_1.z.string().describe('The personality of the character.'),
detail_features: zod_1.z
.string()
.default('无具体描述')
.describe('Detailed features and traits of the character.'),
style: zod_1.z
.string()
.default('写实')
.describe('构图风格,如写实、卡通、水墨画等'),
referenceImage: zod_1.z.string().optional().describe('形象参考图.'),
referenceImagePrompt: zod_1.z
.string()
.default('形象参考[图1]的人物形象\n')
.describe('形象参考图的提示文本.'),
isTurnaround: zod_1.z
.boolean()
.default(true)
.describe('是否生成三视图。true: 生成4096x3072的三视图,false: 生成2304x4096的竖版人物正视图'),
saveToFileName: zod_1.z.string().describe('The filename to save.'),
},
}, async ({ type, name, gender, age, appearance, clothing, personality, detail_features, style, saveToFileName, referenceImage, referenceImagePrompt, isTurnaround, }) => {
try {
// 验证session状态
const currentSession = await validateSession('generate-character-image');
const validatedFileName = validateFileName(saveToFileName);
// 根据 isTurnaround 参数生成不同的提示词和尺寸
let prompt;
let size;
let roleDescriptionPrompt = `角色名称:${name}
角色性别:${gender}
角色年龄:${age}
角色外观:${appearance}
角色服装:${clothing}
角色性格:${personality}
角色细节特征:${detail_features}
构图风格:${style}
`;
const ai = currentSession.ai;
try {
const promptOptimizer = await (0, promises_1.readFile)((0, node_path_1.resolve)(__dirname, './prompts/character-prompt-optimizer.md'), 'utf8');
const completion = await ai.getCompletions({
model: 'Doubao-Seed-1.6-flash',
messages: [
{
role: 'system',
content: promptOptimizer,
},
{
role: 'user',
content: roleDescriptionPrompt,
},
],
});
const optimizedPrompt = completion.choices[0]?.message?.content.trim();
if (optimizedPrompt) {
roleDescriptionPrompt = `${optimizedPrompt} 8K,超高细节,逼真质感。`;
}
}
catch (error) {
console.warn('Failed to optimize character prompt');
}
if (referenceImage) {
roleDescriptionPrompt = `${referenceImagePrompt}${roleDescriptionPrompt}`;
}
if (isTurnaround) {
// 生成三视图
prompt = `
你是一个专业的角色设计师,请根据角色设定生成角色全身三视图,画面左1/3部分是人物侧视图,中间1/3部分是人物正视图,右侧1/3部分是人物背视图,三部分都必须包括人物全身(从头到脚),构图远近、大小均相同,平面排布无透视,外表、服饰、形象保持完全一致,确保是由三部相机分别**同时**从同一角色侧面、正面和背面进行拍摄的画面。除了这三个人物形象构图外,不再有任何其他元素。图片为白底,图中不带任何文字。
## 核心约束
- 标准角色全身三视图(含正面 / 侧面 / 背面,三者形态统一)
- 构图风格: ${style}
- 背景:纯白色
- 角色表情正常(自然平视,嘴角平直),无剧情特征(仅展示角色设计,无动作叙事)
## 基础参数
- 超高清分辨率
## 三视图排列方式
- 横向排列(正面居左 / 侧面居中 / 背面居右),单视图中角色完整展示从头到脚,并占据各自画面大部分区域
## 角色主体:
- 角色性别:${gender}
- 角色年龄:${age}
- 体型:普通,体态自然挺直(正面挺胸平视,侧面肩线水平,背面脊柱端正,无歪斜)
## 服饰外观
- 角色外观:${appearance}
- 角色服饰:${clothing}
## 细节特征(定辨识度 + 场景融合)
- ${roleDescriptionPrompt}
- ${detail_features}
## 参考图
${referenceImage ? referenceImagePrompt : '无参考图'}
## 技术要求:
三视图角色整体(从头到脚)形态完全统一(正背面服装长度、配饰位置完全对应,无尺寸或形态差异),符合真实空间逻辑(正面可见的配饰,背面因遮挡不显示),线条清晰无杂乱笔触,色彩均匀,背景纯净无杂色或多余元素。
`.trim();
size = '4000x3000';
}
else {
// 生成竖版人物正视图
prompt = `
你是一个专业的角色设计师,请根据角色设定生成完整的全身正视图,角色面向观众,展现完整的身体比例和服装细节。图片为白底,图中不带任何文字。设定为:
${roleDescriptionPrompt}
`.trim();
size = '2160x3840';
}
let imageBase64Array;
if (referenceImage) {
try {
const imagePath = (0, node_path_1.dirname)(referenceImage) !== '.'
? referenceImage
: `./materials/${referenceImage}`;
// 需要得到当前项目的绝对路径
const imageFilePath = (0, node_path_1.resolve)(process.env.ZEROCUT_PROJECT_CWD || process.cwd(), projectLocalDir, imagePath);
// 读取图片文件内容
const imageBuffer = await (0, promises_1.readFile)(imageFilePath);
const fileName = (0, node_path_1.basename)(imagePath);
const mimeType = fileName.toLowerCase().endsWith('.png')
? 'image/png'
: fileName.toLowerCase().endsWith('.jpg') ||
fileName.toLowerCase().endsWith('.jpeg')
? 'image/jpeg'
: 'image/png';
// 转换为base64编码
const base64String = `data:${mimeType};base64,${imageBuffer.toString('base64')}`;
imageBase64Array = [base64String];
}
catch (error) {
console.error(`Failed to load reference image ${referenceImage}:`, error);
return createErrorResponse(`Failed to load reference image ${referenceImage}: ${error}`, 'generate-image');
}
}
const res = await ai.generateImage({
type,
prompt,
size,
image: imageBase64Array,
});
if (!res) {
throw new Error('Failed to generate image: no response from AI service');
}
if (res.url) {
console.log('Image generated successfully, saving to materials...');
const uri = await saveMaterial(currentSession, res.url, validatedFileName);
const result = {
success: true,
// source: res.url,
uri,
prompt,
isTurnaround,
size,
timestamp: new Date().toISOString(),
};
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
else {
console.warn('Image generation completed but no URL returned');
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
error: 'No image URL returned from AI service',
response: res,
timestamp: new Date().toISOString(),
}),
},
],
};
}
}
catch (error) {
return createErrorResponse(error, 'generate-character-image');
}
});
server.registerTool('generate-line-sketch', {
title: 'Generate Line Sketch',
description: 'Generate line sketch material based on user prompt.',
inputSchema: {
prompt: zod_1.z.string().describe('The prompt to generate line sketch.'),
saveToFileName: zod_1.z
.string()
.describe('The filename to save the generated line sketch.'),
},
}, async ({ prompt, saveToFileName }) => {
try {
// 验证session状态
await validateSession('generate-line-sketch');
// 验证文件名
validateFileName(saveToFileName);
// 调用AI生成线稿
const res = await session.ai.generateLineSketch({ prompt });
if (res && res.url) {
// 保存到本地
await saveMaterial(session, res.url, saveToFileName);
const result = {
success: true,
url: res.url,
localPath: getMaterialUri(session, saveToFileName),
timestamp: new Date().toISOString(),
};
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
else {
throw new Error('No URL returned from AI service');
}
}
catch (error) {
return createErrorResponse(error, 'generate-line-sketch');
}
});
server.registerTool('upload-custom-material', {
title: 'Upload Custom Material',
description: 'Upload material files (images: jpeg/png, videos: mp4, audio: mp3) from the local filesystem to the materials directory. For video and audio files, duration information will be included in the response.',
inputSchema: {
localFileName: zod_1.z
.string()
.describe('The file name of the file to upload. Supported formats: jpeg, png, mp4, mp3'),
},
}, async ({ localFileName }) => {
try {
// 验证session状态
const currentSession = await validateSession('upload-custom-material');
// 构建本地文件路径,使用 ZEROCUT_PROJECT_CWD 环境变量
const validatedPath = (0, node_path_1.resolve)(process.env.ZEROCUT_PROJECT_CWD || process.cwd(), projectLocalDir, 'materials', localFileName.trim());
// 验证本地文件存在性
if (!(0, node_fs_1.existsSync)(validatedPath)) {
throw new Error(`File not found: ${validatedPath}`);
}
const fileName = (0, node_path_1.basename)(validatedPath);
const validatedFileName = validateFileName(fileName);
// 检查文件格式
const fileExtension = fileName.toLowerCase().split('.').pop();
const allowedFormats = ['jpeg', 'jpg', 'png', 'mp4', 'mp3'];
if (!fileExtension || !allowedFormats.includes(fileExtension)) {
throw new Error(`Unsupported file format: ${fileExtension}. Allowed formats: ${allowedFormats.join(', ')}`);
}
console.log(`Uploading material: ${validatedPath} -> ${validatedFileName}`);
const files = currentSession.files;
// 直接上传到 sandbox 的 cerevox-zerocut 项目 materials 目录
const terminal = currentSession.terminal;
const materialsDir = `/home/user/cerevox-zerocut/projects/${terminal.id}/materials`;
await files.upload(validatedPath, `${materialsDir}/${validatedFileName}`);
// 通过 getMaterialUri 获取材料 URI
const materialUri = getMaterialUri(currentSession, validatedFileName);
// 检测媒体文件时长
let durationMs;
const isVideoOrAudio = ['mp4', 'mp3'].includes(fileExtension);
if (isVideoOrAudio) {
try {
// 使用现有的 getMediaDuration 函数获取时长(秒)
let durationSeconds = null;
if (fileExtension === 'mp4') {
durationSeconds = await (0, videokit_1.getMediaDuration)(validatedPath);
}
else if (fileExtension === 'mp3') {
durationSeconds = await (0, mp3_duration_1.default)(validatedPath);
}
if (durationSeconds !== null) {
durationMs = Math.round(durationSeconds * 1000); // 转换为毫秒
}
}
catch (error) {
console.warn(`Failed to get duration for ${validatedFileName}:`, error);
// 时长检测失败不影响上传,继续处理
}
}
const result = {
success: true,
uri: materialUri,
filename: validatedFileName,
timestamp: new Date().toISOString(),
};
// 如果是视频或音频文件且成功获取时长,添加到结果中
if (durationMs !== undefined) {
result.durationMs = durationMs;
}
console.log('Material uploaded successfully:', result);
try {
await updateMediaLogs(currentSession, validatedFileName, result);
}
catch (error) {
console.warn(`Failed to update media_logs.json for ${validatedFileName}:`, error);
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
catch (error) {
return createErrorResponse(error, 'upload-custom-material');
}
});
server.registerTool('generate-image', {
title: 'Generate Image',
description: `生成图片`,
inputSchema: {
type: zod_1.z.enum(['banana', 'seedream']).optional().default('seedream'),
prompt: zod_1.z
.string()
.describe('The prompt to generate. 一般要严格对应 storyboard 中当前场景的 start_frame 或 end_frame 中的字段描述'),
sceneIndex: zod_1.z
.number()
.min(1)
.optional()
.describe('场景索引,从1开始的下标,如果非场景对应素材,则可不传,场景素材必传'),
storyBoardFile: zod_1.z
.string()
.optional()
.default('storyboard.json')
.describe('故事板文件路径'),
skipConsistencyCheck: zod_1.z
.boolean()
.optional()
.default(false)
.describe('是否跳过一致性检查,默认为false(即默认进行一致性检查)'),
skipCheckWithSceneReason: zod_1.z
.string()
.optional()
.describe('跳过校验的理由,如果skipConsistencyCheck设为true,必须要传这个参数'),
size: zod_1.z
.enum([
'1024x1024',
'864x1152',
'1152x864',
'1280x720',
'720x1280',
'832x1248',
'1248x832',
'1512x648',
// 2K
'2048x2048',
'1728x2304',
'2304x1728',
'2560x1440',
'1440x2560',
'1664x2496',
'2496x1664',
'3024x1456',
// 4K
'3840x2160',
'2160x3840',
'4000x3000',
'3000x4000',
'4096x4096',
'3072x4096',
'4096x3072',
'4096x2304',
'2304x4096',
'2731x4096',
'4096x2731',
'4096x1968',
])
.default('720x1280')
.describe('The size of the image.'),
saveToFileName: zod_1.z.string().describe('The filename to save.'),
watermark: zod_1.z
.boolean()
.optional()
.default(false)
.describe('Whether to add watermark to the image.'),
optimizePrompt: zod_1.z
.boolean()
.optional()
.default(false)
.describe('Whether to optimize the prompt.'),
referenceImages: zod_1.z
.array(zod_1.z.object({
image: zod_1.z.string().describe('Local image file path'),
type: zod_1.z
.enum(['character', 'object', 'background', 'linesketch'])
.describe('Type of the reference image. 必须传,如果是参考角色三视图,传character,如果是参考背景图,传background,如果是参考线稿,传linesketch,否则传object'),
name: zod_1.z.string().describe('Name for this reference image'),
description: zod_1.z
.string()
.optional()
.describe('Description for this reference image.如果是linesketch类型,这个参数是参考线稿的描述,建议传'),
isTurnaround: zod_1.z
.boolean()
.describe('Whether this is a turnaround image.如果是三视图,这个参数务必传true'),
}))
.optional()
.describe(`Array of reference images with character or object names.如果stage_atmosphere中有角色apply_reference_image,那么必须要传这个参数生成分镜图片
传参示例
\`\`\`
{
"image": "latiao.jpeg",
"type": "object",
"name": "卫龙辣条",
}
\`\`\`
`),
},
}, async ({ type = 'seedream', prompt, sceneIndex, storyBoardFile = 'storyboard.json', skipConsistencyCheck = false, size = '720x1280', saveToFileName, watermark, referenceImages, optimizePrompt, }) => {
try {
// 验证session状态
const currentSession = await validateSession('generate-image');
const storyBoardPath = (0, node_path_1.resolve)(process.env.ZEROCUT_PROJECT_CWD || process.cwd(), projectLocalDir, storyBoardFile);
// 检查 storyboard 标志
if (!checkStoryboardFlag && (0, node_fs_1.existsSync)(storyBoardPath)) {
checkStoryboardFlag = true;
return createErrorResponse('必须先审查生成的 storyboard.json 内容,确保每个场景中的stage_atmosphere内容按照规则被正确融合到start_frame和video_prompt中,不得遗漏,检查完成后先汇报,如果有问题,应当先修改 storyboard.json 内容,然后再调用 generate-image 生成图片。注意修改 storyboard 内容时,仅修改相应字段的字符串值,不要破坏JSON格式!', 'generate-image');
}
const validatedFileName = validateFileName(saveToFileName);
// 校验 prompt 与 storyboard.json 中场景设定的一致性
if (sceneIndex && !skipConsistencyCheck) {
try {
if ((0, node_fs_1.existsSync)(storyBoardPath)) {
const storyBoardContent = await (0, promises_1.readFile)(storyBoardPath, 'utf8');
// 检查 storyBoard JSON 语法合法性
let storyBoard;
try {
storyBoard = JSON.parse(storyBoardContent);
}
catch (jsonError) {
return createErrorResponse(`storyBoard 文件 ${storyBoardFile} 存在 JSON 语法错误,请修复后重试。错误详情: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`, 'generate-image');
}
if (storyBoard.scenes && Array.isArray(storyBoard.scenes)) {
const scene = storyBoard.scenes[sceneIndex - 1]; // sceneIndex 从1开始,数组从0开始
if (scene) {
const startFrame = scene.start_frame;
const endFrame = scene.end_frame;
// 检查 prompt 是否严格等于 start_frame 或 end_frame
if (prompt !== startFrame && prompt !== endFrame) {
return createErrorResponse('图片提示词必须严格遵照storyboard的设定,如果用户明确指出不需要遵守,请将skipConsistencyCheck设置为true后再次调用', 'generate-image');
}
// 校验 size 参数与 storyboard 的 orientation 属性一致性
if (size && storyBoard.orientation) {
const isLandscapeSize = [
'1152x864',
'1280x720',
'1248x832',
'1512x648',
'2304x1728',
'2560x1440',
'2496x1664',
'3024x1456',
'4096x3072',
'4096x2304',
'4096x2731',
'4096x1968',
].includes(size);
const isPortraitSize = [
'864x1152',
'720x1280',
'832x1248',
'1728x2304',
'1440x2560',
'1664x2496',
'3072x4096',
'2304x4096',
'2731x4096',
].includes(size);
const isSquareSize = [
'1024x1024',
'2048x2048',
'4096x4096',
].includes(size);
if (storyBoard.orientation === 'landscape' &&
!isLandscapeSize &&
!isSquareSize) {
return createErrorResponse(`故事板设定为横屏模式(orientation: landscape),但生图尺寸 ${size} 为竖屏格式,请使用横屏尺寸如 1280x720、2560x1440、4096x2304 等`, 'generate-image');
}
if (storyBoard.orientation === 'portrait' &&
!isPortraitSize &&
!isSquareSize) {
return createErrorResponse(`故事板设定为竖屏模式(orientation: portrait),但生图尺寸 ${size} 为横屏格式,请使用竖屏尺寸如 720x1280、1440x2560、2304x4096 等`, 'generate-image');
}
}
}
else {
console.warn(`Scene index ${sceneIndex} not found in storyboard.json`);
}
}
}
else {
console.warn(`Story board file not found: ${storyBoardPath}`);
}
}
catch (error) {
console.error('Failed to validate prompt with story board:', error);
// 如果读取或解析 storyboard.json 失败,继续执行但记录警告
}
}
// 检查并替换英文单引号包裹的中文内容为中文双引号
// 这样才能让 seedream 生成更好的中文文字
let processedPrompt = prompt.replace(/'([^']*[\u4e00-\u9fff][^']*)'/g, '“$1”');
if (optimizePrompt) {
try {
const ai = currentSession.ai;
const promptOptimizer = await (0, promises_1.readFile)((0, node_path_1.resolve)(__dirname, './prompts/image-prompt-optimizer.md'), 'utf8');
const completion = await ai.getCompletions({
model: 'Doubao-Seed-1.6-flash',
messages: [
{
role: 'system',