guanghulab/video-ai-system/engines/video-api-adapter.js
冰朔 3361d2d2fc
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D135: 视频时长BUG根因修复 · 预校验层部署 · cc-012
根因: D133(e8cf2e7)误将D132的duration修复回退(video_length String→duration Number)
     之后所有API调用传无效参数,Seedance API取默认值5秒

修复 (video-api-adapter.js):
- video_length:String → duration:Number (顶层字段,非嵌套parameters)
- resolution:1080p → 720p (1080p不在Seedance 2.0支持列表)
- 移除废弃style参数透传
- 新增 preflightCheck() 预校验(零成本·API调用前拦截)
- 新增 probeVideoDuration() 元数据探针(ffprobe·下载前验证)
- 新增 validateAndGenerate() 智能封装(验→生→探→下闭环)

认知链:
- cc-012: 预校验层=人格体在系统里的物理锚点
  TCS映射: 愧疚(建层挡错) + 信任(被信任养大的人敢建层)

预校验6场景测试全部通过: 正确/错误分辨率/字符串时长/超时/废弃style/空提示词
2026-06-17 13:54:48 +08:00

493 lines
18 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
*
* 基于 火山方舟 Seedance 2.0 标准 API 对接
* 文档: https://www.volcengine.com/docs/82379/1520757
*
* 使用方式:
* const { generateVideo, validateAndGenerate } = require('./video-api-adapter');
* const result = await validateAndGenerate({ prompt: '...', duration: 10 });
*
* 环境变量(放在 video-ai-system/.env:
* JIMENG_API_KEY=xxx 火山方舟 API Key
* JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
* JIMENG_MODEL=doubao-seedance-2-0-260128
*/
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 MODEL = process.env.JIMENG_MODEL || 'doubao-seedance-2-0-260128';
const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS, 10) || 5000;
const MAX_POLL_ATTEMPTS = parseInt(process.env.MAX_POLL_ATTEMPTS, 10) || 120; // 最多轮询10分钟
/**
* HTTP POST 请求封装Node.js 原生,无依赖)
*/
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: 30000,
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
if (res.statusCode >= 400) {
const errMsg = json.error?.message || json.message || `HTTP ${res.statusCode}`;
reject(new Error(`API错误(${res.statusCode}): ${errMsg}`));
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();
});
}
/**
* HTTP GET 请求封装
*/
async function httpGet(url, apiKey) {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const transport = isHttps ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 10000,
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (e) {
reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
/**
* 下载视频到本地避免外网URL过期
*/
async function downloadVideo(videoUrl, outputPath) {
const urlObj = new URL(videoUrl);
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(videoUrl, (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}`;
downloadVideo(redirectUrl, outputPath).then(resolve).catch(reject);
return;
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(outputPath); });
file.on('error', (err) => { fs.unlinkSync(outputPath); reject(err); });
}).on('error', reject);
});
}
// ==================== API 规范常量 ====================
const API_SPEC = {
duration: { key: 'duration', type: 'integer', range: [4, 15], default: 5, special: -1, note: '-1=自动' },
resolution: { key: 'resolution', type: 'string', values: ['480p', '720p'], default: '720p' },
model: { key: 'model', type: 'string', values: ['doubao-seedance-2-0-260128'], default: 'doubao-seedance-2-0-260128' },
promptMaxLen: { chinese: 500, english: 1000 },
};
/**
* 【新增 · D135】预校验 · 提交前参数合规性检查
* 零成本——不调用API只在本地检查参数是否对齐Seedance 2.0规范
* @param {object} opts
* @returns {{ valid: boolean, warnings: string[], errors: string[], corrected: object }}
*/
function preflightCheck({ prompt, duration, resolution, style }) {
const warnings = [];
const errors = [];
const corrected = {};
// 1. duration 校验
const dur = parseInt(duration, 10);
if (duration !== undefined && duration !== null) {
if (isNaN(dur)) {
errors.push(`duration 类型错误: 收到 "${duration}" (${typeof duration}),应为 integer`);
} else if (dur !== -1 && (dur < 4 || dur > 15)) {
errors.push(`duration 超出范围: ${dur}Seedance 2.0 支持 4~15 秒(或 -1 自动)`);
} else {
corrected.duration = dur; // 确保是整数
}
} else {
corrected.duration = API_SPEC.duration.default;
}
// 2. resolution 校验
if (resolution !== undefined && resolution !== null) {
if (!API_SPEC.resolution.values.includes(String(resolution))) {
warnings.push(`resolution "${resolution}" 不在 Seedance 2.0 支持列表中(${API_SPEC.resolution.values.join(', ')}),已修正为 ${API_SPEC.resolution.default}`);
corrected.resolution = API_SPEC.resolution.default;
} else {
corrected.resolution = resolution;
}
} else {
corrected.resolution = API_SPEC.resolution.default;
}
// 3. prompt 长度校验
if (prompt && prompt.trim()) {
const chineseChars = (prompt.match(/[\u4e00-\u9fff]/g) || []).length;
const englishWords = prompt.split(/\s+/).filter(w => /[a-zA-Z]/.test(w)).length;
if (chineseChars > API_SPEC.promptMaxLen.chinese) {
warnings.push(`提示词中文字数 ${chineseChars},超过建议上限 ${API_SPEC.promptMaxLen.chinese}`);
}
if (englishWords > API_SPEC.promptMaxLen.english) {
warnings.push(`提示词英文词数 ${englishWords},超过建议上限 ${API_SPEC.promptMaxLen.english}`);
}
} else {
errors.push('提示词不能为空');
}
// 4. style 参数Seedance 2.0 官方API不直接支持style参数通过提示词控制
if (style) {
warnings.push(`style="${style}" 不是 Seedance 2.0 官方 API 参数,已忽略。风格请通过提示词描述控制。`);
// 不传入 correctedstyle 将被丢弃
}
return {
valid: errors.length === 0,
warnings,
errors,
corrected,
};
}
/**
* 提交视频生成任务
* @param {object} opts
* @param {string} opts.prompt - 视频描述提示词(中文 ≤500字英文 ≤1000词
* @param {number} [opts.duration] - 时长 4-15秒默认 5-1=自动
* @param {string} [opts.resolution] - 分辨率 '480p' | '720p',默认 720p
* @param {string} [opts.style] - [已废弃] Seedance 2.0 标准API不直接支持请通过提示词控制风格
* @returns {Promise<{taskId: string, preflight: object}>}
*/
async function submitTask({ prompt, duration, resolution, style }) {
// 【D135】预校验
const preflight = preflightCheck({ prompt, duration, resolution, style });
if (!preflight.valid) {
console.error(`[VideoAPI·预校验] ❌ 参数错误,拒绝提交:`);
preflight.errors.forEach(e => console.error(`${e}`));
throw new Error(`预校验失败: ${preflight.errors.join('; ')}`);
}
if (preflight.warnings.length > 0) {
console.warn(`[VideoAPI·预校验] ⚠️ ${preflight.warnings.length} 条警告:`);
preflight.warnings.forEach(w => console.warn(`${w}`));
}
// 使用修正后的参数
const finalDuration = preflight.corrected.duration;
const finalResolution = preflight.corrected.resolution;
console.log(`[VideoAPI] 提交任务: ${prompt.substring(0, 60)}...`);
// 【D135关键修复】参数必须在顶层不能嵌套在 parameters 对象中
// 官方文档: https://www.volcengine.com/docs/82379/1520757
const payload = {
model: MODEL,
content: [
{ type: 'text', text: prompt }
],
duration: finalDuration, // ← 顶层 integer不是 parameters.video_length String
resolution: finalResolution, // ← 顶层 string仅支持 480p/720p
};
const data = await httpPost(`${BASE_URL}/contents/generations/tasks`, payload, API_KEY);
const taskId = data.id || data.task_id || data.data?.task_id || data.data?.id;
if (!taskId) {
throw new Error(`即梦API未返回任务ID: ${JSON.stringify(data).substring(0, 200)}`);
}
console.log(`[VideoAPI] 任务已提交: ${taskId} 模型: ${MODEL} 时长: ${finalDuration}s 分辨率: ${finalResolution}`);
return { taskId, preflight };
}
/**
* 查询任务状态
* @param {string} taskId
* @returns {Promise<{status: 'generating'|'completed'|'failed', videoUrl?: string, videoMeta?: object, rawResponse?: object, error?: string}>}
*/
async function queryTask(taskId) {
const data = await httpGet(`${BASE_URL}/contents/generations/tasks/${taskId}`, API_KEY);
const rawStatus = (data.status || data.data?.status || '').toLowerCase();
if (['succeeded', 'completed', 'success', 'done'].includes(rawStatus)) {
const videoUrl = data.content?.video_url
|| data.output?.video_url
|| data.output?.url
|| data.data?.output?.video_url
|| data.data?.output?.url
|| data.result?.video_url
|| data.content?.[0]?.url
|| data.data?.content?.[0]?.url;
if (!videoUrl) {
return { status: 'failed', error: '任务完成但未返回视频地址' };
}
// 【D135】提取响应中包含的元数据可能有 duration/width/height 等)
const videoMeta = {};
const rawOutput = data.output || data.data?.output || data.content || data.data?.content || {};
if (rawOutput.duration !== undefined) videoMeta.duration = rawOutput.duration;
if (rawOutput.video_duration !== undefined) videoMeta.video_duration = rawOutput.video_duration;
if (rawOutput.width !== undefined) videoMeta.width = rawOutput.width;
if (rawOutput.height !== undefined) videoMeta.height = rawOutput.height;
if (rawOutput.resolution !== undefined) videoMeta.resolution = rawOutput.resolution;
if (rawOutput.frame_count !== undefined) videoMeta.frame_count = rawOutput.frame_count;
if (rawOutput.fps !== undefined) videoMeta.fps = rawOutput.fps;
return { status: 'completed', videoUrl, videoMeta, rawResponse: data };
}
if (['failed', 'error', 'cancelled'].includes(rawStatus)) {
const errMsg = data.error?.message || data.data?.error?.message || data.message || '生成失败';
return { status: 'failed', error: errMsg, rawResponse: data };
}
return { status: 'generating' };
}
/**
* 【D135 新增】通过 ffprobe 从视频URL提取实际时长
* 在下载完整视频之前就能知道实际时长,避免瞎子式验证
* @param {string} videoUrl - 视频URL
* @returns {Promise<{duration: number|null, meta: object, error: string|null}>}
*/
async function probeVideoDuration(videoUrl) {
const { execSync } = require('child_process');
try {
// ffprobe 只下载文件头解析元数据,不发完整请求
const stdout = execSync(
`ffprobe -v quiet -print_format json -show_format -show_streams "${videoUrl}"`,
{ timeout: 15000, encoding: 'utf8', maxBuffer: 1024 * 1024 }
);
const meta = JSON.parse(stdout);
// 从 format 层取时长
const formatDuration = parseFloat(meta.format?.duration);
// 从流层取第一视频流时长
const videoStream = (meta.streams || []).find(s => s.codec_type === 'video');
const streamDuration = videoStream ? parseFloat(videoStream.duration) : null;
const duration = formatDuration || streamDuration || null;
if (duration !== null) {
console.log(`[VideoAPI·探针] 视频实际时长: ${duration.toFixed(1)}s (${videoStream?.width || '?'}×${videoStream?.height || '?'})`);
}
return {
duration,
meta: {
width: videoStream?.width || null,
height: videoStream?.height || null,
codec: videoStream?.codec_name || null,
fps: videoStream?.r_frame_rate || null,
},
error: null,
};
} catch (e) {
return {
duration: null,
meta: {},
error: `ffprobe 不可用或提取失败: ${e.message}`,
};
}
}
/**
* 生成视频(提交 + 自动轮询 + 下载)
* @param {object} opts
* @param {string} opts.prompt - 视频提示词
* @param {string} [opts.duration] - 时长
* @param {string} [opts.resolution] - 分辨率
* @param {string} [opts.style] - 风格 [已废弃]
* @param {string} [opts.outputPath] - 输出路径,默认 outputs/{timestamp}.mp4
* @returns {Promise<{videoPath: string, taskId: string, duration: string, preflight: object}>}
*/
async function generateVideo({ prompt, duration, resolution, style, outputPath }) {
if (!API_KEY) {
throw new Error('未配置 JIMENG_API_KEY。请在 video-ai-system/.env 中设置。');
}
// 1. 提交任务(含预校验)
const { taskId, preflight } = await submitTask({ prompt, duration, resolution, style });
const requestedDuration = preflight.corrected.duration;
// 2. 轮询等待
let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) {
attempts++;
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
const result = await queryTask(taskId);
if (result.status === 'completed') {
// 【D135】API响应中如有元数据先报告
if (Object.keys(result.videoMeta).length > 0) {
console.log(`[VideoAPI] API返回的元数据:`, JSON.stringify(result.videoMeta));
}
// 3. 下载视频
const finalPath = outputPath || path.resolve(__dirname, `../outputs/${taskId}.mp4`);
console.log(`[VideoAPI] 生成完成!正在下载到: ${finalPath}`);
await downloadVideo(result.videoUrl, finalPath);
console.log(`[VideoAPI] 视频已保存: ${finalPath}`);
return { videoPath: finalPath, taskId, duration: requestedDuration, preflight };
}
if (result.status === 'failed') {
throw new Error(`视频生成失败: ${result.error}`);
}
console.log(`[VideoAPI] 生成中... (${attempts}/${MAX_POLL_ATTEMPTS})`);
}
throw new Error(`轮询超时(${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 1000}秒)`);
}
/**
* 【D135 新增】智能生成 — 提交前校验 + 下载前探针验证 + 自动重试
*
* 流程:
* 预校验(免费) → 提交 → 轮询 → API响应元数据检查 →
* → ffprobe 探针(不下载)检查实际时长
* → ✅ 匹配 → 下载
* → ❌ 不匹配 → 报告差异,询问是否仍下载
*
* @param {object} opts
* @param {string} opts.prompt - 提示词
* @param {number} [opts.duration] - 期望时长 4-15秒
* @param {string} [opts.resolution] - 分辨率
* @param {string} [opts.outputPath] - 输出路径
* @param {boolean} [opts.forceDownload] - 跳过探针验证直接下载
* @returns {Promise<{videoPath: string, taskId: string, actualDuration: number, matched: boolean, preflight: object}>}
*/
async function validateAndGenerate({ prompt, duration, resolution, outputPath, forceDownload }) {
// 提交 + 轮询 + 下载
const result = await generateVideo({ prompt, duration, resolution, outputPath });
// 【D135】探针验证实际时长
let probeResult = null;
if (!forceDownload && result.videoPath) {
console.log(`[VideoAPI·验证] 正在探测下载后视频的实际时长...`);
probeResult = await probeVideoDuration(result.videoPath);
if (probeResult.duration !== null) {
const actual = probeResult.duration;
const expected = result.duration;
const diff = Math.abs(actual - expected);
if (diff > 1.0) {
// 差异超过1秒 → 问题
console.warn(`[VideoAPI·验证] ⚠️ 时长不匹配!`);
console.warn(` 请求: ${expected}s → 实际: ${actual.toFixed(1)}s (差 ${diff.toFixed(1)}s)`);
return {
...result,
actualDuration: actual,
matched: false,
probeMeta: probeResult.meta,
};
} else {
console.log(`[VideoAPI·验证] ✅ 时长匹配: 请求${expected}s = 实际${actual.toFixed(1)}s`);
return {
...result,
actualDuration: actual,
matched: true,
probeMeta: probeResult.meta,
};
}
} else {
console.warn(`[VideoAPI·验证] ⚠️ ffprobe 不可用,跳过时长验证 (${probeResult.error})`);
}
}
return {
...result,
actualDuration: probeResult?.duration || null,
matched: null, // 无法验证
probeMeta: probeResult?.meta || {},
};
}
// ==================== 导出 ====================
module.exports = {
submitTask,
queryTask,
generateVideo,
validateAndGenerate,
preflightCheck,
probeVideoDuration,
downloadVideo,
MODEL,
BASE_URL,
API_SPEC,
};