diff --git a/video-ai-system/engines/image-api-adapter.js b/video-ai-system/engines/image-api-adapter.js new file mode 100644 index 0000000..3ef3f02 --- /dev/null +++ b/video-ai-system/engines/image-api-adapter.js @@ -0,0 +1,288 @@ +/** + * 光湖视频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/ + * + * 环境变量(复用 video-ai-system/.env): + * 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 envPath = path.resolve(__dirname, '../.env'); +if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + envContent.split('\n').forEach(line => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...vals] = trimmed.split('='); + if (key && vals.length) process.env[key.trim()] = vals.join('=').trim(); + } + }); +} + +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 || '', + '正面胸像,面部清晰可见,中性表情,均匀自然光,纯色灰色背景,高质量,真人写实,亚洲面孔' + ].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, +};