cang-ying/engines/image-api-adapter.js
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 ICE-GL-ZY001
LL-172-20260707
冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用
原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱

迁移:
  ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等)
  ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis)

排除:
  ⊢ outputs/ (视频产物)
  ⊢ test-input/ test-output/ (测试)
  ⊢ data/ (临时数据)
  ⊢ preview-001/002 (旧产片)
  ⊢ 旧分镜/旧提示词/旧导演编码

后续:
  ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位
  ⊢ 苍耳+鉴影 写新东西进本仓
  ⊢ GLOBAL-SEARCH 加 cang-ying 仓库

铸渊 ICE-GL-ZY001 · 2026-07-07 D167
冰朔 ICE-GL∞ 主权
2026-07-07 10:20:10 +08:00

280 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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