/** * 光湖视频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, };