280 lines
9.4 KiB
JavaScript
280 lines
9.4 KiB
JavaScript
|
|
/**
|
|||
|
|
* 光湖视频AI系统 · 图片API适配层
|
|||
|
|
* D135 · 铸渊 ICE-GL-ZY001
|
|||
|
|
*
|
|||
|
|
* 基于 火山方舟 Seedream 4.0 API
|
|||
|
|
* 文档: https://www.volcengine.com/docs/82379/1541523
|
|||
|
|
*
|
|||
|
|
* 用途: 为视频生成器的「参考图模式」提供角色标准人设图
|
|||
|
|
* 同一套 API Key,同一个 BASE_URL,图片和视频共享计费
|
|||
|
|
*
|
|||
|
|
* 使用方式:
|
|||
|
|
* const { generateImage } = require('./image-api-adapter');
|
|||
|
|
* const result = await generateImage({ prompt: '...', size: '2K' });
|
|||
|
|
*
|
|||
|
|
* 输出路径优先级 (与视频一致):
|
|||
|
|
* 1. opts.outputPath(显式指定)
|
|||
|
|
* 2. 外接硬盘 JZAO /Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片/
|
|||
|
|
* 3. 本地 fallback video-ai-system/outputs/images/
|
|||
|
|
*
|
|||
|
|
* 环境变量(由 LOCAL-SECRETS-PATH.hdlp 的苍耳本机路径加载):
|
|||
|
|
* JIMENG_API_KEY=xxx 火山方舟 API Key (与视频共享)
|
|||
|
|
* JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const https = require('https');
|
|||
|
|
const http = require('http');
|
|||
|
|
const { loadVideoAiEnv } = require('./env-loader');
|
|||
|
|
|
|||
|
|
// ==================== 环境变量 ====================
|
|||
|
|
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
|
|||
|
|
|
|||
|
|
const API_KEY = process.env.JIMENG_API_KEY || '';
|
|||
|
|
const BASE_URL = process.env.JIMENG_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
|
|||
|
|
const IMAGE_MODEL = 'doubao-seedream-4-0-250828';
|
|||
|
|
|
|||
|
|
// ==================== 输出路径 ====================
|
|||
|
|
const JZAO_IMAGE_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片';
|
|||
|
|
const LOCAL_IMAGE_ROOT = path.resolve(__dirname, '../outputs/images');
|
|||
|
|
const IMAGE_OUTPUT_ROOT = (() => {
|
|||
|
|
if (fs.existsSync(JZAO_IMAGE_ROOT)) return JZAO_IMAGE_ROOT;
|
|||
|
|
return LOCAL_IMAGE_ROOT;
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// ==================== API 规范 ====================
|
|||
|
|
const IMAGE_API_SPEC = {
|
|||
|
|
model: { values: [IMAGE_MODEL], default: IMAGE_MODEL },
|
|||
|
|
size: {
|
|||
|
|
presets: ['2K', '4K'],
|
|||
|
|
specific: ['2048x2048', '2304x1728', '1728x2304', '2560x1440', '1440x2560', '2496x1664', '1664x2496', '3024x1296'],
|
|||
|
|
default: '2048x2048',
|
|||
|
|
description: '2K/4K 或具体像素值,总像素 921600~16777216',
|
|||
|
|
},
|
|||
|
|
promptMaxLen: { chinese: 300, english: 600 },
|
|||
|
|
responseFormat: { values: ['url', 'b64_json'], default: 'url' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== HTTP 工具(与 video-api-adapter 同款,独立以避免循环依赖) ====================
|
|||
|
|
|
|||
|
|
async function httpPost(url, body, apiKey) {
|
|||
|
|
const urlObj = new URL(url);
|
|||
|
|
const isHttps = urlObj.protocol === 'https:';
|
|||
|
|
const transport = isHttps ? https : http;
|
|||
|
|
const payload = JSON.stringify(body);
|
|||
|
|
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const req = transport.request(url, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${apiKey}`,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Content-Length': Buffer.byteLength(payload),
|
|||
|
|
},
|
|||
|
|
timeout: 60000,
|
|||
|
|
}, (res) => {
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', chunk => data += chunk);
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
const json = JSON.parse(data);
|
|||
|
|
if (res.statusCode >= 400) {
|
|||
|
|
reject(new Error(`API错误(${res.statusCode}): ${json.error?.message || json.message || '未知'}`));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
resolve(json);
|
|||
|
|
} catch (e) {
|
|||
|
|
reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
req.on('error', reject);
|
|||
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
|||
|
|
req.write(payload);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function downloadImage(imageUrl, outputPath) {
|
|||
|
|
const urlObj = new URL(imageUrl);
|
|||
|
|
const isHttps = urlObj.protocol === 'https:';
|
|||
|
|
const transport = isHttps ? https : http;
|
|||
|
|
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const dir = path.dirname(outputPath);
|
|||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
|
|||
|
|
const file = fs.createWriteStream(outputPath);
|
|||
|
|
transport.get(imageUrl, (res) => {
|
|||
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|||
|
|
const redirectUrl = res.headers.location.startsWith('http')
|
|||
|
|
? res.headers.location
|
|||
|
|
: `${urlObj.protocol}//${urlObj.host}${res.headers.location}`;
|
|||
|
|
downloadImage(redirectUrl, outputPath).then(resolve).catch(reject);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
res.pipe(file);
|
|||
|
|
file.on('finish', () => { file.close(); resolve(outputPath); });
|
|||
|
|
file.on('error', (err) => { try { fs.unlinkSync(outputPath); } catch (_) {} reject(err); });
|
|||
|
|
}).on('error', reject);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveImagePath(filename) {
|
|||
|
|
const dir = IMAGE_OUTPUT_ROOT;
|
|||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
return path.join(dir, filename);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 预校验 ====================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 图片参数预校验
|
|||
|
|
*/
|
|||
|
|
function preflightImageCheck({ prompt, size }) {
|
|||
|
|
const warnings = [];
|
|||
|
|
const errors = [];
|
|||
|
|
const corrected = {};
|
|||
|
|
|
|||
|
|
if (!prompt || !prompt.trim()) {
|
|||
|
|
errors.push('提示词不能为空');
|
|||
|
|
} else {
|
|||
|
|
const chineseChars = (prompt.match(/[\u4e00-\u9fff]/g) || []).length;
|
|||
|
|
if (chineseChars > IMAGE_API_SPEC.promptMaxLen.chinese) {
|
|||
|
|
warnings.push(`提示词 ${chineseChars} 字,超过建议 ${IMAGE_API_SPEC.promptMaxLen.chinese} 字`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (size) {
|
|||
|
|
const s = String(size);
|
|||
|
|
if (IMAGE_API_SPEC.size.presets.includes(s) || IMAGE_API_SPEC.size.specific.includes(s)) {
|
|||
|
|
corrected.size = s;
|
|||
|
|
} else {
|
|||
|
|
warnings.push(`size "${size}" 不在支持列表中,使用默认 ${IMAGE_API_SPEC.size.default}`);
|
|||
|
|
corrected.size = IMAGE_API_SPEC.size.default;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
corrected.size = IMAGE_API_SPEC.size.default;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { valid: errors.length === 0, warnings, errors, corrected };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 生成图片 ====================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成图片(同步接口,无需轮询)
|
|||
|
|
* @param {string} opts.prompt - 提示词
|
|||
|
|
* @param {string} [opts.size] - 尺寸,默认 2048x2048
|
|||
|
|
* @param {string} [opts.outputPath] - 输出路径
|
|||
|
|
* @param {string} [opts.filename] - 文件名(如 char-003-subai-ref.png)
|
|||
|
|
* @returns {Promise<{imagePath: string, imageUrl: string, size: string, usage: object}>}
|
|||
|
|
*/
|
|||
|
|
async function generateImage({ prompt, size, outputPath, filename }) {
|
|||
|
|
if (!API_KEY) {
|
|||
|
|
throw new Error('未配置 JIMENG_API_KEY');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const preflight = preflightImageCheck({ prompt, size });
|
|||
|
|
if (!preflight.valid) {
|
|||
|
|
console.error('[ImageAPI·预校验] ❌', preflight.errors.join('; '));
|
|||
|
|
throw new Error(`预校验失败: ${preflight.errors.join('; ')}`);
|
|||
|
|
}
|
|||
|
|
if (preflight.warnings.length > 0) {
|
|||
|
|
console.warn('[ImageAPI·预校验] ⚠️', preflight.warnings.join('; '));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const finalSize = preflight.corrected.size;
|
|||
|
|
|
|||
|
|
console.log(`[ImageAPI] 提交文生图: ${prompt.substring(0, 60)}...`);
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
model: IMAGE_MODEL,
|
|||
|
|
prompt: prompt.trim(),
|
|||
|
|
size: finalSize,
|
|||
|
|
response_format: 'url',
|
|||
|
|
watermark: false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const data = await httpPost(`${BASE_URL}/images/generations`, payload, API_KEY);
|
|||
|
|
|
|||
|
|
const imageUrl = data.data?.[0]?.url || data.url || data.data?.[0]?.b64_json;
|
|||
|
|
if (!imageUrl) {
|
|||
|
|
throw new Error(`API未返回图片URL: ${JSON.stringify(data).substring(0, 200)}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const finalPath = outputPath || resolveImagePath(filename || `image-${Date.now()}.png`);
|
|||
|
|
|
|||
|
|
console.log(`[ImageAPI] 生成完成,下载到: ${finalPath}`);
|
|||
|
|
await downloadImage(imageUrl, finalPath);
|
|||
|
|
|
|||
|
|
const usage = data.usage || {};
|
|||
|
|
console.log(`[ImageAPI] ✅ 已保存: ${finalPath} 消耗: ${usage.total_tokens || '?'} tokens`);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
imagePath: finalPath,
|
|||
|
|
imageUrl,
|
|||
|
|
size: finalSize,
|
|||
|
|
usage,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 角色参考图 ====================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成角色标准人设图(用于视频参考图模式)
|
|||
|
|
* @param {string} charId - 角色编号,如 'CHAR-003'
|
|||
|
|
* @param {object} l0Descriptor - L0底层编码描述
|
|||
|
|
* @param {object} l1Config - L1服装配置
|
|||
|
|
* @returns {Promise<{imagePath: string, charId: string}>}
|
|||
|
|
*/
|
|||
|
|
async function generateCharacterRef({ charId, l0Descriptor, l1Config }) {
|
|||
|
|
const prompt = [
|
|||
|
|
l0Descriptor,
|
|||
|
|
l1Config?.clothing || '',
|
|||
|
|
'正面胸像,面部清晰可见,中性表情,均匀自然光,纯色灰色背景,高质量,3D动画渲染,中国风仙侠动态漫,亚洲面孔'
|
|||
|
|
].filter(Boolean).join(',');
|
|||
|
|
|
|||
|
|
const filename = `${charId.toLowerCase()}-ref-portrait.png`;
|
|||
|
|
|
|||
|
|
console.log(`[ImageAPI·角色参考图] 生成 ${charId} 人设图...`);
|
|||
|
|
|
|||
|
|
const result = await generateImage({
|
|||
|
|
prompt,
|
|||
|
|
size: '1728x2304', // 3:4 竖屏胸像
|
|||
|
|
filename,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 注册索引
|
|||
|
|
const registryPath = path.resolve(__dirname, '../outputs/image-registry.json');
|
|||
|
|
let registry = { _meta: { updated: new Date().toISOString() }, chars: {} };
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(registryPath)) registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
|
|||
|
|
} catch (_) {}
|
|||
|
|
registry.chars[charId] = {
|
|||
|
|
charId,
|
|||
|
|
imagePath: result.imagePath,
|
|||
|
|
prompt: prompt.substring(0, 120),
|
|||
|
|
size: result.size,
|
|||
|
|
generatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
registry._meta.updated = new Date().toISOString();
|
|||
|
|
const dir = path.dirname(registryPath);
|
|||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8');
|
|||
|
|
|
|||
|
|
return { imagePath: result.imagePath, charId };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
generateImage,
|
|||
|
|
generateCharacterRef,
|
|||
|
|
preflightImageCheck,
|
|||
|
|
downloadImage,
|
|||
|
|
resolveImagePath,
|
|||
|
|
IMAGE_MODEL,
|
|||
|
|
IMAGE_OUTPUT_ROOT,
|
|||
|
|
IMAGE_API_SPEC,
|
|||
|
|
};
|