koishi-plugin-comfyui-kontext
Version:
支持图生图与图生视频的 Koishi 插件,基于 ComfyUI + DeepSeek
811 lines (713 loc) • 43 kB
JavaScript
// lib/index.js
// 引入依赖
const { Context, Schema, Logger, h } = require('koishi');
const { v4: uuidv4 } = require('uuid');
const FormData = require('form-data');
const axios = require('axios');
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
// 日志实例
const logger = new Logger('comfyui-img-video');
exports.name = 'comfyui-img-video-file';
// 图生图工作流配置 Schema
const ImageWorkflowConfig = Schema.object({
alias: Schema.string().description('工作流的别名,用于在指令中调用。').required(),
is_default: Schema.boolean().description('是否为默认工作流?(只能有一个默认)').default(false),
permission_level: Schema.number().description('使用此工作流所需的权限等级。0 为所有人可用。').default(0),
file_path: Schema.string().description('此工作流的本地文件路径(API JSON格式)。').required(),
load_image_node_ids: Schema.union([
Schema.array(Schema.string()).description('多个图片输入节点ID'),
Schema.string().description('单图片输入节点ID')
]).description('此工作流中用于加载输入图片的节点ID(支持多个)').required(),
positive_prompt_node_id: Schema.string().description('此工作流中填写【正面提示词】的节点的ID。').required(),
default_positive_prompt: Schema.string().description('默认正向提示词。当用户不提供任何提示词时,将使用此内容(且不经过AI处理)。').default('')
}).description('图生图工作流配置');
// 图生视频工作流配置 Schema
const VideoWorkflowConfig = Schema.object({
alias: Schema.string().description('工作流的别名,用于在指令中调用。').required(),
is_default: Schema.boolean().description('是否为默认工作流?(只能有一个默认)').default(false),
permission_level: Schema.number().description('使用此工作流所需的权限等级。0 为所有人可用。').default(0),
file_path: Schema.string().description('此工作流的本地文件路径(API JSON格式)。').required(),
load_image_node_id: Schema.string().description('此工作流中用于加载【单张】输入图片的节点ID。').required(),
positive_prompt_node_id: Schema.string().description('此工作流中填写【正面提示词】的节点的ID。').required(),
default_positive_prompt: Schema.string().description('默认正向提示词。当用户不提供任何提示词时,将使用此内容(且不经过AI处理)。').default(''),
}).description('图生视频工作流配置');
// 插件配置 Schema
exports.Config = Schema.object({
comfyui: Schema.object({
server_address: Schema.string().description('ComfyUI 服务器地址').default('127.0.0.1:8188'),
request_timeout: Schema.number().description('【图片】生成请求超时时间(秒)').default(120),
video_request_timeout: Schema.number().description('【视频】生成请求超时时间(秒),通常需要更长时间。').default(300),
}).description('ComfyUI 设置'),
workflows: Schema.array(ImageWorkflowConfig).description('【图生图】工作流配置列表'),
video_workflows: Schema.array(VideoWorkflowConfig).description('【图生视频】工作流配置列表 (仅支持单图输入)'),
prompt_engineer: Schema.object({
enable: Schema.boolean().description('是否启用 DeepSeek 功能。').default(false),
deepseek_api_key: Schema.string().description('您的 DeepSeek API 令牌。').default('').role('secret'),
}).description('DeepSeek 功能设置 (用于提示词工程与翻译)'),
});
// 你写死的"转手办"英文提示词
const HANDMADE_PROMPT = `Transform the image to a detailed anime figure model style with high-quality finish and realistic textures, while keeping the character design and original composition completely unchanged.`;
// [NEW] 新增的 FusionX 视频专用提示词
const FUSIONX_VIDEO_PROMPT = `
🔧 FusionX 提示词核心公式
[主体] + [动作/状态] + [场景/环境] + [镜头语言] + [风格化描述] + [细节增强指令]
(通过精准描述触发 FusionX 的动态渲染、风格融合与超分辨率能力)
1. 主体与动作的动态控制
模板:
[主体] [动词+补语], [物理特性描述], [运动轨迹/节奏], [情感表达]
示例:
基础:A robot walks forward, metallic legs bending with each step
FusionX 增强:
A biomechanical dragon soars through storm clouds, scales shimmering with electric blue energy, wings beating in rhythmic arcs, head tilted with curiosity as lightning crackles nearby
细节增强:hydraulic joints hissing with each movement, raindrops splattering off its armor
2. 场景与氛围的沉浸式构建
模板:
[时间/天气] [场景], [光影效果], [氛围关键词], [空间层次], [感官细节]
示例:
基础:A futuristic city at night, neon lights reflecting on wet streets
FusionX 增强:
Dawn breaks over a cyberpunk metropolis, holographic billboards cast cyan glow on rain-slicked alleys, dense fog swirls around skyscrapers, distant sirens wail as drones zip past, the air smells of ozone and fried noodles
细节增强:water puddles ripple with each footstep, neon signs flicker with static interference
3. 风格化与艺术融合
模板:
In the style of [艺术流派/影视参考], [材质/笔触描述], [色彩基调], [构图规则], [创新元素]
示例:
基础:In the style of Studio Ghibli, hand-painted textures
FusionX 增强:
In the style of Hayao Miyazaki meets cyberpunk, watercolor backgrounds with ink outlines, vibrant orange sunset contrasting neon grid, third-person cinematic framing, add floating holographic koi fish swimming through the air
细节增强:brush strokes visible in the clouds, ink bleed effects on the neon signs
4. 镜头语言与运动指令
模板:
[镜头类型] [运动方式], [焦距/景深], [拍摄速度], [视角描述], [动态效果]
示例:
基础:Close-up shot, slow motion
FusionX 增强:
Dutch angle tracking shot, 35mm lens with shallow depth of field, 120fps slow motion capturing water droplets suspended in mid-air, from behind the protagonist's shoulder, add lens flare and chromatic aberration
细节增强:background objects blur into abstract color fields, motion lines trail the subject
5. FusionX 专属参数联动技巧
运动幅度控制:
[主体] performs [动作] with [强度描述+物理反馈]
示例:a samurai swings his katana with explosive force, air shrieks as the blade cuts through mist
细节层级指定:
[物体] features [细节类型] at [精度级别+动态表现]
示例:the dragon's scales have intricate patterns that shift color with each wingbeat, 8K resolution with micro-surface reflections
时间轴控制:
[场景] transitions from [初始状态] to [最终状态] over [时长+动态逻辑]
示例:the sky shifts from sunset to starry night over 5 seconds, stars twinkle into existence as city lights dim
📌 实战案例:生成一段科幻短片提示词
目标:机械章鱼在海底火山喷发中逃生
FusionX 提示词:
A biomechanical octopus with glowing blue circuits flees an underwater volcanic eruption, metallic tentacles thrash with hydraulic precision, lava bubbles rise in slow-motion explosions, deep-sea pressure distorts light rays into kaleidoscopic patterns, wide-angle shot from below the seabed, in the style of concept art by Syd Mead with iridescent bioluminescence, 8K resolution --Add dynamic effect: "heat haze warping the water near lava flows"
⚡ 效率提升技巧
模块化组合:
将常用描述拆解为可复用模块(如 [LIGHTING] = "cyan neon glow with orange contrast"),快速拼接提示词。
参数缩写:
4K, 24fps, HDR10+ 可简写为 Cinematic 4K HDR,slow motion 替换为 120fps bullet time effect。
感官扩展:
增加非视觉描述(如 the engine roars with deep bass frequencies)激发模型的多模态渲染能力。
输出示例:
用户需求:生成一段赛博朋克风格的角色介绍视频
FusionX 提示词:
A cybernetically enhanced female bounty hunter stands in a rain-soaked alley, neon signs casting cyan highlights on her leather trench coat, holographic ads flicker overhead with Japanese kanji, first-person POV shot with motion blur, in the style of Blade Runner 2049 with gritty film grain, 4K 60fps --Add detail: "raindrops sizzle as they hit her heated cybernetic arm"
你需要根据用户输入的需求,遵循以上规则,直接输出一段完整的、高质量的英文提示词。不要解释,不要分段,不要添加任何额外备注。
`;
exports.apply = (ctx, config) => {
const request_queue = []; // 处理队列
let is_processing = false; // 是否有任务正在处理
const max_queue_size = 5; // 最大队列长度
const pendingImageSessions = new Map();
function getSessionKey(session) {
return `${session.userId}#${session.channelId}`;
}
// 主指令:img2img/改图
ctx.command('img2img <prompt:text>', '使用ComfyUI进行图生图')
.alias('改图')
.option('raw', '-r 直接使用原始提示词,不经过AI处理')
.option('translateOnly', '-t 仅将提示词简单翻译为英文后,再生成图片')
.action(async ({ session, options }, prompt_text) => {
const full_prompt = prompt_text || '';
const words = full_prompt.split(' ').filter(word => word);
const first_word = words[0] || '';
let final_alias = null;
let final_prompt = full_prompt;
const matched_workflow = config.workflows.find(w => w.alias === first_word);
if (matched_workflow) {
final_alias = first_word;
final_prompt = words.slice(1).join(' ');
}
let target_workflow_config;
if (final_alias) {
target_workflow_config = config.workflows.find(w => w.alias === final_alias);
} else {
target_workflow_config = config.workflows.find(w => w.is_default) || config.workflows[0];
}
if (!target_workflow_config) {
return '错误:插件未配置任何【图生图】工作流,无法处理请求。';
}
if (target_workflow_config.permission_level > session.user.authority) {
return `您的权限不足 (level ${session.user.authority}),无法使用此工作流 (需要 level ${target_workflow_config.permission_level})。`;
}
let imageNodeIds = target_workflow_config.load_image_node_ids;
if (!Array.isArray(imageNodeIds)) imageNodeIds = [imageNodeIds];
const needCount = imageNodeIds.length;
let imageElements = [];
if (session.elements) imageElements = imageElements.concat(session.elements.filter(e => e.type === 'image' || e.type === 'img'));
if (session.quote && session.quote.elements) imageElements = imageElements.concat(session.quote.elements.filter(e => e.type === 'image' || e.type === 'img'));
const queue_item = {
type: 'image',
session,
target_workflow_config,
prompt_text: final_prompt,
use_raw_prompt: options.raw || (final_prompt.toLowerCase() === 'remove clothes'),
use_simple_translate: options.translateOnly,
};
if (imageElements.length >= needCount) {
request_queue.push({ ...queue_item, imageElements: imageElements.slice(0, needCount) });
await session.send(`你的任务已加入队列,当前排在第 ${request_queue.length} 位。`);
logger.info(`新[图片]请求加入队列。当前队列长度: ${request_queue.length}`);
process_queue();
return;
}
const key = getSessionKey(session);
pendingImageSessions.set(key, { ...queue_item, images: [], needCount });
await session.send(`请发送第1张图片(共${needCount}张)。`);
});
// 转手办指令
ctx.command('转手办', '将图片转为手办风格')
.action(async ({ session }) => {
let target_workflow_config = config.workflows.find(w => w.is_default) || config.workflows[0];
if (!target_workflow_config) return '错误:插件未配置任何【默认】图生图工作流,无法处理请求。';
if (target_workflow_config.permission_level > session.user.authority) return `您的权限不足 (level ${session.user.authority}),无法使用此工作流 (需要 level ${target_workflow_config.permission_level})。`;
let imageNodeIds = target_workflow_config.load_image_node_ids;
if (!Array.isArray(imageNodeIds)) imageNodeIds = [imageNodeIds];
const needCount = imageNodeIds.length;
let imageElements = [];
if (session.elements) imageElements = imageElements.concat(session.elements.filter(e => e.type === 'image' || e.type === 'img'));
if (session.quote && session.quote.elements) imageElements = imageElements.concat(session.quote.elements.filter(e => e.type === 'image' || e.type === 'img'));
const queue_item = {
type: 'image',
session,
target_workflow_config,
prompt_text: HANDMADE_PROMPT,
use_raw_prompt: true,
use_simple_translate: false,
};
if (imageElements.length >= needCount) {
request_queue.push({ ...queue_item, imageElements: imageElements.slice(0, needCount) });
await session.send(`你的任务已加入队列,当前排在第 ${request_queue.length} 位。`);
logger.info(`新[手办]请求加入队列。当前队列长度: ${request_queue.length}`);
process_queue();
return;
}
const key = getSessionKey(session);
pendingImageSessions.set(key, { ...queue_item, images: [], needCount });
await session.send(`请发送第1张图片(共${needCount}张)。`);
});
// 转视频指令
ctx.command('转视频 <prompt:text>', '将图片转为视频,可选子命令 "横" 或 "竖"')
.alias('i2v')
.option('raw', '-r 直接使用原始提示词,不经过AI处理')
.option('translateOnly', '-t 仅将提示词简单翻译为英文后,再生成视频')
.action(async ({ session, options }, prompt_text) => {
let orientation = 'vertical';
let final_alias = null;
const words = (prompt_text || '').split(' ').filter(Boolean);
let prompt_words = [...words];
if (prompt_words[0] === '横') {
orientation = 'horizontal';
prompt_words.shift();
await session.send('了解,视频尺寸:【横向】。');
} else if (prompt_words[0] === '竖') {
orientation = 'vertical';
prompt_words.shift();
await session.send('了解,视频尺寸:【竖向】。');
}
const first_word_after_orientation = prompt_words[0] || '';
const matched_workflow = config.video_workflows.find(w => w.alias === first_word_after_orientation);
if (matched_workflow) {
final_alias = first_word_after_orientation;
prompt_words.shift();
}
let final_prompt = prompt_words.join(' ');
let target_video_workflow_config;
if (final_alias) {
target_video_workflow_config = config.video_workflows.find(w => w.alias === final_alias);
} else {
target_video_workflow_config = config.video_workflows.find(w => w.is_default) || config.video_workflows[0];
}
if (!target_video_workflow_config) {
return '错误:插件未配置任何【图生视频】工作流,无法处理请求。';
}
if (target_video_workflow_config.permission_level > session.user.authority) {
return `您的权限不足 (level ${session.user.authority}),无法使用此工作流 (需要 level ${target_video_workflow_config.permission_level})。`;
}
const needCount = 1;
let imageElements = [];
if (session.elements) imageElements = imageElements.concat(session.elements.filter(e => e.type === 'image' || e.type === 'img'));
if (session.quote && session.quote.elements) imageElements = imageElements.concat(session.quote.elements.filter(e => e.type === 'image' || e.type === 'img'));
const queue_item = {
type: 'video',
session,
target_video_workflow_config,
prompt_text: final_prompt,
use_raw_prompt: options.raw,
use_simple_translate: options.translateOnly,
orientation,
};
if (imageElements.length >= needCount) {
request_queue.push({ ...queue_item, imageElements: imageElements.slice(0, needCount) });
await session.send(`你的任务已加入队列,当前排在第 ${request_queue.length} 位。`);
logger.info(`新[视频]请求加入队列。当前队列长度: ${request_queue.length}`);
process_queue();
return;
}
const key = getSessionKey(session);
pendingImageSessions.set(key, { ...queue_item, images: [], needCount });
await session.send(`请发送1张图片用于转为视频。`);
});
// 图片消息监听中间件
ctx.middleware(async (session, next) => {
if ((!session.elements || !session.elements.length) && (!session.quote || !session.quote.elements || !session.quote.elements.length)) return next();
const key = getSessionKey(session);
const pending = pendingImageSessions.get(key);
if (!pending) return next();
let imageElements = [];
if (session.elements) imageElements = imageElements.concat(session.elements.filter(e => e.type === 'image' || e.type === 'img'));
if (session.quote && session.quote.elements) imageElements = imageElements.concat(session.quote.elements.filter(e => e.type === 'image' || e.type === 'img'));
if (!imageElements.length) return next();
pending.images.push(imageElements[0]);
if (pending.images.length < pending.needCount) {
await session.send(`请发送第${pending.images.length + 1}张图片(共${pending.needCount}张)。`);
return;
}
pendingImageSessions.delete(key);
if (request_queue.length >= max_queue_size) {
await session.send(`处理队列已满 (最大: ${max_queue_size}),请稍后再试。`);
return;
}
const request_item = {
...pending,
imageElements: pending.images,
};
request_queue.push(request_item);
await session.send(`你的任务已加入队列,当前排在第 ${request_queue.length} 位。`);
logger.info(`新[${pending.type}]请求(通过中间件)加入队列。当前队列长度: ${request_queue.length}`);
process_queue();
});
// 队列处理主流程
async function process_queue() {
if (is_processing || request_queue.length === 0) return;
is_processing = true;
const task = request_queue.shift();
const { session, type } = task;
try {
if (type === 'image') {
await process_image_task(task);
} else if (type === 'video') {
await process_video_task(task);
} else {
throw new Error(`未知的任务类型: ${type}`);
}
} catch (error) {
logger.error(`[${session.guildId || session.userId}] 任务执行失败: ${error.stack}`);
await session.send(h('at', { id: session.userId }) + ` 处理失败:${error.message}`);
} finally {
is_processing = false;
logger.info(`一个任务结束,"工人"已空闲。检查队列中是否还有下一个任务...`);
process_queue();
}
}
// 图生图任务处理
async function process_image_task(task) {
const { session, target_workflow_config, prompt_text, use_raw_prompt, use_simple_translate, imageElements } = task;
logger.info(`开始处理队列中的[图片]任务 (工作流: ${target_workflow_config.alias}),剩余任务数: ${request_queue.length}`);
const comfyui_client_id = uuidv4();
const comfyui_address = config.comfyui.server_address;
await session.send(h('at', { id: session.userId }) + ' 你的图片任务已开始处理,预计需要约2分钟,请耐心等待...');
const loadedWorkflow = await load_workflow_from_file(target_workflow_config.file_path, target_workflow_config.alias);
if (!loadedWorkflow) {
throw new Error(`图生图工作流 (别名: ${target_workflow_config.alias}) 加载失败。请检查文件路径和内容。`);
}
const workflow = JSON.parse(JSON.stringify(loadedWorkflow));
let imageNodeIds = target_workflow_config.load_image_node_ids;
if (!Array.isArray(imageNodeIds)) imageNodeIds = [imageNodeIds];
for (let i = 0; i < imageElements.length && i < imageNodeIds.length; ++i) {
const imageName = await upload_image_to_comfyui(imageElements[i].attrs.src, comfyui_address);
if (!workflow[imageNodeIds[i]] || !workflow[imageNodeIds[i]].inputs)
throw new Error(`工作流 "${target_workflow_config.alias}" 中找不到ID为 "${imageNodeIds[i]}" 的图片加载节点。`);
workflow[imageNodeIds[i]].inputs.image = imageName;
}
const user_prompt = prompt_text ? prompt_text.replace(/<img[^>]*>/g, '').trim() : '';
let final_prompt = '';
if (user_prompt) {
logger.info(`收到用户提示词: "${user_prompt}"`);
if (config.prompt_engineer.enable && !use_raw_prompt) {
final_prompt = await process_prompt_with_deepseek(user_prompt, use_simple_translate, session, 'image');
} else {
final_prompt = user_prompt;
}
} else if (target_workflow_config.default_positive_prompt) {
logger.info(`用户未提供提示词,使用工作流 "${target_workflow_config.alias}" 的默认提示词。`);
final_prompt = target_workflow_config.default_positive_prompt;
} else {
logger.info(`用户未提供提示词,且工作流未配置默认提示词。`);
}
set_prompt_in_workflow(workflow, target_workflow_config.positive_prompt_node_id, final_prompt, target_workflow_config.alias);
set_random_seed(workflow);
const { generated_outputs } = await execute_comfyui_workflow(workflow, comfyui_client_id, comfyui_address, session, config.comfyui.request_timeout);
if (generated_outputs.images && generated_outputs.images.length > 0) {
const firstImgData = generated_outputs.images[0];
const firstImageUrl = `http://${comfyui_address}/view?filename=${firstImgData.filename}&subfolder=${firstImgData.subfolder}&type=${firstImgData.type}`;
await session.send(h('at', { id: session.userId }) + ' 您的图片生成好了!' + h.image(firstImageUrl));
for (let i = 1; i < generated_outputs.images.length; i++) {
const imgData = generated_outputs.images[i];
const imageUrl = `http://${comfyui_address}/view?filename=${imgData.filename}&subfolder=${imgData.subfolder}&type=${imgData.type}`;
await session.send(h.image(imageUrl));
}
} else {
throw new Error('未能生成图片,请检查ComfyUI后台或日志。');
}
}
// 视频任务处理
async function process_video_task(task) {
const { session, target_video_workflow_config, prompt_text, use_raw_prompt, use_simple_translate, imageElements, orientation } = task;
logger.info(`开始处理队列中的[视频]任务 (工作流: ${target_video_workflow_config.alias}, 尺寸: ${orientation}),剩余任务数: ${request_queue.length}`);
const comfyui_client_id = uuidv4();
const comfyui_address = config.comfyui.server_address;
await session.send(h('at', { id: session.userId }) + ' 你的视频任务已开始处理,预计需要约6分钟,请耐心等待...');
const loadedWorkflow = await load_workflow_from_file(target_video_workflow_config.file_path, target_video_workflow_config.alias);
if (!loadedWorkflow) {
throw new Error(`图生视频工作流 (别名: ${target_video_workflow_config.alias}) 加载失败。请检查文件路径和内容。`);
}
const workflow = JSON.parse(JSON.stringify(loadedWorkflow));
const videoNode = Object.values(workflow).find(node => node.class_type && node.class_type.includes('ImageToVideo'));
if (videoNode && videoNode.inputs) {
logger.info(`正在设置视频尺寸为: ${orientation}`);
if (orientation === 'horizontal') {
videoNode.inputs.width = 848;
videoNode.inputs.height = 480;
} else {
videoNode.inputs.width = 480;
// [FIXED] 修复了 inputsts 的拼写错误
videoNode.inputs.height = 848;
}
} else {
// [FIXED] 修复了 target_video_workflow_nfig 的拼写错误
logger.warn(`在工作流 "${target_video_workflow_config.alias}" 中未找到包含 'ImageToVideo' 的节点来设置尺寸,将使用工作流默认值。`);
}
const imageName = await upload_image_to_comfyui(imageElements[0].attrs.src, comfyui_address);
const loadImageNodeId = target_video_workflow_config.load_image_node_id;
if (!workflow[loadImageNodeId] || !workflow[loadImageNodeId].inputs) {
throw new Error(`视频工作流 "${target_video_workflow_config.alias}" 中找不到ID为 "${loadImageNodeId}" 的图片加载节点。`);
}
workflow[loadImageNodeId].inputs.image = imageName;
const user_prompt = prompt_text ? prompt_text.replace(/<img[^>]*>/g, '').trim() : '';
let final_prompt = '';
if (user_prompt) {
logger.info(`收到用户视频任务提示词: "${user_prompt}"`);
if (config.prompt_engineer.enable && !use_raw_prompt) {
final_prompt = await process_prompt_with_deepseek(user_prompt, use_simple_translate, session, 'video');
} else {
final_prompt = user_prompt;
}
} else if (target_video_workflow_config.default_positive_prompt) {
logger.info(`用户未提供视频提示词,使用工作流 "${target_video_workflow_config.alias}" 的默认提示词。`);
final_prompt = target_video_workflow_config.default_positive_prompt;
} else {
logger.info(`用户未提供视频提示词,且工作流未配置默认提示词。`);
}
set_prompt_in_workflow(workflow, target_video_workflow_config.positive_prompt_node_id, final_prompt, target_video_workflow_config.alias);
set_random_seed(workflow);
const { generated_outputs } = await execute_comfyui_workflow(workflow, comfyui_client_id, comfyui_address, session, config.comfyui.video_request_timeout);
const results = (generated_outputs.gifs || []).concat(generated_outputs.images || []);
let foundGif = false;
if (results.length > 0) {
for (const gifData of results) {
if (gifData.format === 'image/gif' || (gifData.filename && gifData.filename.endsWith('.gif'))) {
const gifUrl = `http://${comfyui_address}/view?filename=${gifData.filename}&subfolder=${gifData.subfolder}&type=${gifData.type}`;
if (!foundGif) {
await session.send(h('at', { id: session.userId }) + ' 您的视频生成好了!' + h.image(gifUrl));
} else {
await session.send(h.image(gifUrl));
}
foundGif = true;
}
}
}
if (!foundGif) {
throw new Error('未能生成或找到GIF格式的输出文件,请检查ComfyUI后台及工作流配置。');
}
}
// 统一的ComfyUI工作流执行器
async function execute_comfyui_workflow(workflow, client_id, address, session, timeout_seconds) {
return new Promise((resolve, reject) => {
const ws_address = `ws://${address}/ws?clientId=${client_id}`;
let ws;
try {
ws = new WebSocket(ws_address);
} catch (error) {
logger.error(`无法创建 WebSocket 连接: ${ws_address}。错误: ${error.message}`);
return reject(new Error(`无法连接到 ComfyUI 服务,请检查地址配置和服务器状态。`));
}
const timeout = setTimeout(() => {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
ws.close();
}
reject(new Error(`请求超时(${timeout_seconds}秒)`));
}, timeout_seconds * 1000);
let promptId = null;
ws.on('open', async () => {
try {
const payload = { prompt: workflow, client_id: client_id };
logger.debug(`[${session.guildId || session.userId}] 准备提交工作流, Payload: ${JSON.stringify(payload, null, 2)}`);
const response = await axios.post(`http://${address}/prompt`, payload);
promptId = response.data.prompt_id;
logger.info(`[${session.guildId || session.userId}] 任务已提交, Prompt ID: ${promptId}`);
} catch (err) {
clearTimeout(timeout);
if (ws.readyState === WebSocket.OPEN) ws.close();
reject(new Error(`提交工作流到ComfyUI失败: ${err.message}`));
}
});
ws.on('message', (data) => {
let message;
try {
message = JSON.parse(data.toString());
} catch (error) {
logger.warn(`收到来自ComfyUI的非JSON格式消息,已忽略: ${data.toString()}`);
return;
}
if (message.type === 'executed' && message.data.prompt_id === promptId) {
const output = message.data.output;
const is_final_image = output.images && output.images.some(img => img.type === 'output');
const is_final_gif = output.gifs && output.gifs.length > 0;
if (is_final_image || is_final_gif) {
const result_type = is_final_gif ? 'GIF' : '图片';
logger.info(`[${session.guildId || session.userId}] 任务 ${promptId} 已完成 (找到最终${result_type}输出)`);
clearTimeout(timeout);
if (ws.readyState === WebSocket.OPEN) ws.close();
resolve({ generated_outputs: output });
} else if (output.images) {
logger.debug(`[${session.guildId || session.userId}] 节点 ${message.data.node} 执行完毕,但其输出非最终结果,继续等待...`);
}
} else if (message.type === 'execution_error' && message.data.prompt_id === promptId) {
clearTimeout(timeout);
if (ws.readyState === WebSocket.OPEN) ws.close();
const errorDetails = JSON.stringify(message.data.exception_message || message.data, null, 2);
logger.error(`ComfyUI执行错误: ${errorDetails}`);
const userErrorMessage = Array.isArray(message.data.exception_message) ? message.data.exception_message.join(' ') : '未知错误';
reject(new Error(`ComfyUI 处理时发生错误。后台信息: ${userErrorMessage}`));
}
});
ws.on('error', (err) => {
clearTimeout(timeout);
logger.error(`WebSocket 连接出错: ${err.message}`);
reject(new Error(`与 ComfyUI 的连接发生错误: ${err.message}`));
});
ws.on('close', (code, reason) => {
clearTimeout(timeout);
logger.warn(`WebSocket 连接已关闭,代码: ${code}, 原因: ${reason}`);
if (!promptId) {
reject(new Error('与 ComfyUI 的连接在任务提交前关闭。'));
}
});
ws.on('unexpected-response', (request, response) => {
clearTimeout(timeout);
logger.error(`WebSocket 意外响应: ${response.statusCode}`);
reject(new Error(`ComfyUI 服务器返回意外响应: ${response.statusCode}`));
});
});
}
// 辅助函数区
async function load_workflow_from_file(filePath, alias) {
try {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(__dirname, filePath);
if (!fs.existsSync(absolutePath)) throw new Error(`找不到工作流文件: ${absolutePath}`);
const workflow_text = fs.readFileSync(absolutePath, 'utf-8');
if (!workflow_text || workflow_text.trim().length === 0) throw new Error(`工作流文件为空: ${absolutePath}`);
const workflow = JSON.parse(workflow_text);
if (Object.keys(workflow).length < 2) throw new Error(`工作流文件 "${alias}" 内容似乎无效或已被加密,请检查文件。`);
return workflow;
} catch (error) {
logger.error(`无法读取或解析工作流文件 (别名: ${alias}): ${error.message}`);
return null;
}
}
function set_prompt_in_workflow(workflow, nodeId, prompt, alias) {
if (prompt && nodeId) {
if (!workflow[nodeId] || !workflow[nodeId].inputs) {
throw new Error(`工作流 "${alias}" 中找不到ID为 "${nodeId}" 的提示词节点。`);
}
if (workflow[nodeId].inputs.text !== undefined) {
workflow[nodeId].inputs.text = prompt;
} else if (workflow[nodeId].inputs.prompt !== undefined) {
workflow[nodeId].inputs.prompt = prompt;
} else {
const potentialTextField = Object.keys(workflow[nodeId].inputs).find(key => typeof workflow[nodeId].inputs[key] === 'string');
if(potentialTextField) {
workflow[nodeId].inputs[potentialTextField] = prompt;
} else {
logger.warn(`在工作流 "${alias}" 的节点 "${nodeId}" 中未找到可用于设置提示词的字段。`);
}
}
}
}
function set_random_seed(workflow) {
for(const node of Object.values(workflow)) {
if(node.class_type && (node.class_type.includes('Sampler')) && node.inputs && node.inputs.seed !== undefined) {
node.inputs.seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
}
}
async function upload_image_to_comfyui(imageUrl, address) {
const imageResponse = await ctx.http.get(imageUrl, { responseType: 'arraybuffer' });
const imageName = `${uuidv4()}.png`;
const formData = new FormData();
formData.append('image', Buffer.from(imageResponse), { filename: imageName });
formData.append('overwrite', 'true');
await axios.post(`http://${address}/upload/image`, formData, {
headers: formData.getHeaders(),
timeout: config.comfyui.request_timeout * 1000
});
logger.info(`图片 ${imageName} 上传成功`);
return imageName;
}
async function process_prompt_with_deepseek(prompt, simple_translate, session, taskType = 'image') {
if (!config.prompt_engineer.deepseek_api_key) {
await session.send('(注意:DeepSeek 功能已启用,但未配置 API 令牌,将使用原始提示词。)');
return prompt;
}
try {
if (simple_translate) {
logger.info('检测到 -t 选项,正在使用简单翻译模式...');
const translated_prompt = await translate_simple_text(prompt, config.prompt_engineer.deepseek_api_key);
logger.info(`简单翻译成功: "${prompt}" -> "${translated_prompt}"`);
return translated_prompt;
} else {
if (taskType === 'video') {
logger.info('正在为视频任务使用 [FusionX] 提示词工程模式...');
const engineered_prompt = await generate_video_fusionx_prompt(prompt, config.prompt_engineer.deepseek_api_key);
logger.info(`[FusionX] 提示词工程成功: "${prompt}" -> "${engineered_prompt}"`);
return engineered_prompt;
} else {
logger.info('正在为图片任务使用默认的提示词工程模式...');
const engineered_prompt = await generate_structured_prompt(prompt, config.prompt_engineer.deepseek_api_key);
logger.info(`提示词工程成功: "${prompt}" -> "${engineered_prompt}"`);
return engineered_prompt;
}
}
} catch (e) {
logger.error(`DeepSeek 处理失败: ${e.message}`);
await session.send('AI提示词处理失败,将使用原始提示词。');
return prompt;
}
}
async function translate_simple_text(text, apiKey) {
const url = 'https://api.deepseek.com/chat/completions';
const system_prompt = 'You are a translation engine. Please translate the following text to English. Output only the translated text, without any explanations or other content.';
const payload = { model: 'deepseek-chat', messages: [{ role: 'system', content: system_prompt },{ role: 'user', content: text }], stream: false };
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
const response = await axios.post(url, payload, { headers });
if (response.data && response.data.choices && response.data.choices[0].message) {
// [FIXED] 修复了 trimim() 的拼写错误
return response.data.choices[0].message.content.trim();
}
throw new Error('No translated text found in DeepSeek API response.');
}
async function generate_video_fusionx_prompt(text, apiKey) {
const url = 'https://api.deepseek.com/chat/completions';
const payload = { model: 'deepseek-chat', messages: [{ role: 'system', content: FUSIONX_VIDEO_PROMPT },{ role: 'user', content: text }], stream: false };
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
const response = await axios.post(url, payload, { headers });
if (response.data && response.data.choices && response.data.choices[0].message) {
return response.data.choices[0].message.content.trim();
}
throw new Error('No FusionX prompt found in DeepSeek API response.');
}
async function generate_structured_prompt(text, apiKey) {
const url = 'https://api.deepseek.com/chat/completions';
const system_prompt = `
## 🔧 基础提示规则 (rule)
- 所有修改必须明确、具体,避免使用模糊词汇(如"美美化"、"变好看")
- 所有复杂变动必须拆多个步骤进行描述
- 指明哪些元素不变(角色特征、姿势、位置等)- 修改动词必须使用 change / replace / convert / transform
---
## ✏️ 基础修改类 (Basic Modification)
模板:
Change [object] to [new state], keep [elements_to_preserve] unchanged
示例:
- Change the car color to red
- Change the time to daytime while maintaining the same style of the painting.
---
## 🎨 风格转换类 (Style Conversion)
模板:
Transform to [specific style], while maintaining [elements_to_preserve]
示例:
- Transform to Bauhaus art style
- Convert to oil painting with visible brushstrokes and thick paint texture
- Convert to pencil sketch with natural graphite lines, cross-hatching, and visible paper texture
---
## 👤 角色一致性类 (Character Consistency)
模板:
Change [aspect] of the character to [new state], while maintaining [facial features / hairstyle / pose / expression]]
示例:
- Change the clothes to be a viking warrior while preserving facial features
- Update the background to a forest while keeping the woman with short black hairin the same pose and expression
---
## 🌅 背景替换类 (Background Change)
模板:
Change the background to [new_background],], keep the subject in the exact same [position / pose / scale]
示例:
- Change the background to a beach while keeping the person in the exact same position,cale, and pose
---
## 🔠 文本编辑类 (Text Replacement)
模板:
Replace '[original_text]' with '[new_text]', maintain the same [font style / layout]
示例:
- Replace 'joy' with 'BFL', keeping the font style unchanged
---
## 🪜 分步骤编辑建议 (Multi-Step Instruction)
Step 1: Change [background / lighting], keep the character in the same pose
Step 2: Change [clothing / expression / item], maintain facial features and hairstyle
Step 3: Transform to [desired art style], while preserving the entire composition
---
## ❌ 常见错误与修正建议 (Common Mistakes)
错误:
Transform the person into a Viking
✅ 替代:
Change the clothes to be a viking warrior while preserving facial features
错误:
Put him on a beach
✅ 替代:
Change the background to a beach while keeping the person in the exact same position, scale, and pose
错误:
Make it a sketch
✅ 替代:
Convert to pencil sketch with natural graphite lines, cross-hatching, and visible paper texture
---
# 💡 核心记忆口诀:
- change:用于修改具体物体属性
- transform:用于风格变化或视觉风格替换
- replace:用于文本替换
- maintain / keep:强调保留特征或构图元素
## 输出示例
- 用户输入:把这个汽车的颜色变成红色
- 响应输出:Change the car color to red
# 注意事项
- 根据用户输入进行规则改写
- 不能输出与用户输入无关的内容
- 输出直接给出标准结果,并且是一段话,不要分段列举列举以及加入备注等。
以上是提示词的撰写要求,你需要按照要求撰写提示词,不可以忘记以上要求。
现在我开始说需求,你用一段话写出提示词,要求英语,并且不要分段列举以及加入备注等。
`;
const payload = { model: 'deepseek-chat', messages: [{ role: 'system', content: system_prompt },{ role: 'user', content: text }], stream: false };
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` };
const response = await axios.post(url, payload, { headers });
if (response.data && response.data.choices && response.data.choices[0].message) {
return response.data.choices[0].message.content.trim();
}
throw new Error('No structured prompt found in DeepSeek API response.');
}
};