#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, ''); const model = process.env.DEYUN_VIDEO_MODEL || 'deyun-seedance-2.0-canary'; const expectedPlatform = process.env.DEYUN_VIDEO_PLATFORM || '漫路(火山兼容)'; const expectedProviderModel = process.env.DEYUN_VIDEO_PROVIDER_MODEL || 'doubao-seedance-2-0'; const pollIntervalMs = positiveInteger(process.env.DEYUN_VIDEO_POLL_INTERVAL_MS, 5000); const taskTimeoutMs = positiveInteger(process.env.DEYUN_VIDEO_TASK_TIMEOUT_MS, 20 * 60 * 1000); const outputPath = process.env.DEYUN_VIDEO_E2E_OUTPUT || `artifacts/deyun-video-e2e-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`; const database = { container: process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres', name: process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway', user: process.env.GATEWAY_E2E_DB_USER || 'easyai', }; const validationCases = [ { name: '480p-4s-audio-off', request: { model, prompt: 'A glossy red cube rotates slowly on a clean white studio table, locked camera, soft daylight.', resolution: '480p', ratio: '16:9', duration: 4, generate_audio: false, seed: 41001, watermark: false, runMode: 'real', }, expected: { height: 480, duration: 4, audio: false, ratio: 16 / 9, }, }, { name: '720p-6s-audio-on', request: { model, prompt: 'A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.', resolution: '720p', ratio: '16:9', duration: 6, generate_audio: true, seed: 42002, watermark: false, runMode: 'real', }, expected: { height: 720, duration: 6, audio: true, ratio: 16 / 9, }, }, ]; const requestedCases = new Set( String(process.env.DEYUN_VIDEO_CASES || '') .split(',') .map((value) => value.trim()) .filter(Boolean), ); if (requestedCases.size > 0) { const selectedCases = validationCases.filter((validationCase) => requestedCases.has(validationCase.name)); if (selectedCases.length !== requestedCases.size) { const knownCases = validationCases.map((validationCase) => validationCase.name).join(', '); throw new Error(`Unknown DEYUN_VIDEO_CASES value; known cases: ${knownCases}`); } validationCases.splice(0, validationCases.length, ...selectedCases); } const submittedTaskIds = []; function assert(condition, message) { if (!condition) throw new Error(message); } function positiveInteger(value, fallback) { const parsed = Number.parseInt(String(value || ''), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function nearlyEqual(left, right, tolerance = 1e-6) { return Math.abs(Number(left) - Number(right)) <= tolerance; } function runPSQL(query) { return execFileSync( 'docker', ['exec', database.container, 'psql', '-U', database.user, '-d', database.name, '-At', '-F', '\t', '-c', query], { encoding: 'utf8' }, ).trim(); } function resolveGatewayAPIKey() { if (process.env.GATEWAY_E2E_API_KEY) { assert(process.env.GATEWAY_E2E_API_KEY_ID, 'Set GATEWAY_E2E_API_KEY_ID when GATEWAY_E2E_API_KEY is provided'); const metadata = apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID); return { ...metadata, secret: process.env.GATEWAY_E2E_API_KEY, source: 'environment', }; } const query = ` SELECT key.id::text, key.key_secret, key.gateway_user_id::text, wallet.balance::float8, wallet.frozen_balance::float8 FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.currency = 'resource' AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND COALESCE(key.key_secret, '') <> '' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (key.scopes ? 'video' OR key.scopes ? '*' OR key.scopes ? 'all') AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`; const fields = runPSQL(query).split('\t'); assert(fields.length === 5 && fields[0] && fields[1], 'No recoverable video-scoped Gateway API Key with positive balance was found'); return { keyId: fields[0], secret: fields[1], userId: fields[2], balance: Number(fields[3]), frozenBalance: Number(fields[4]), source: 'database', }; } function apiKeyMetadata(keyId) { const escapedKeyId = String(keyId).replaceAll("'", "''"); const output = runPSQL(` SELECT key.id::text, key.gateway_user_id::text, wallet.balance::float8, wallet.frozen_balance::float8 FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.currency = 'resource' WHERE key.id = '${escapedKeyId}'::uuid LIMIT 1`); const fields = output.split('\t'); assert(fields.length === 4 && fields[0], `Gateway API Key ${keyId} was not found`); return { keyId: fields[0], userId: fields[1], balance: Number(fields[2]), frozenBalance: Number(fields[3]), }; } function walletState(userId) { const escapedUserId = String(userId).replaceAll("'", "''"); const fields = runPSQL(` SELECT balance::float8, frozen_balance::float8 FROM gateway_wallet_accounts WHERE gateway_user_id = '${escapedUserId}'::uuid AND currency = 'resource' LIMIT 1`).split('\t'); assert(fields.length === 2, `Wallet for Gateway user ${userId} was not found`); return { balance: Number(fields[0]), frozenBalance: Number(fields[1]), }; } async function request(path, init = {}) { const response = await fetch(`${baseURL}${path}`, { ...init, headers: { Authorization: `Bearer ${authentication.secret}`, ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers || {}), }, }); const text = await response.text(); if (!response.ok) { throw new Error(`${init.method || 'GET'} ${path} failed ${response.status}: ${text}`); } return text ? JSON.parse(text) : {}; } async function createVideoTask(body) { const accepted = await request('/api/v1/videos/generations', { method: 'POST', headers: { 'X-Async': 'true' }, body: JSON.stringify(body), }); const taskId = accepted.taskId || accepted.task?.id; assert(taskId, `Video task acceptance is missing taskId: ${JSON.stringify(accepted)}`); submittedTaskIds.push(taskId); return taskId; } async function pollTask(taskId) { const startedAt = Date.now(); while (Date.now() - startedAt < taskTimeoutMs) { const task = await request(`/api/v1/tasks/${taskId}`); if (task.status === 'succeeded') return task; if (task.status === 'failed' || task.status === 'cancelled') { throw new Error(`Task ${taskId} ended as ${task.status}: ${task.errorMessage || task.error || JSON.stringify(task.result)}`); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } throw new Error(`Timed out after ${taskTimeoutMs}ms waiting for task ${taskId}`); } async function taskEvents(taskId) { const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, { headers: { Authorization: `Bearer ${authentication.secret}` }, }); const text = await response.text(); if (!response.ok) throw new Error(`GET task events failed ${response.status}: ${text}`); return text .split(/\r?\n\r?\n/) .flatMap((block) => { const data = block .split(/\r?\n/) .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trim()) .join('\n'); return data ? [JSON.parse(data)] : []; }); } async function taskPreprocessingLogs(taskId) { return request(`/api/v1/tasks/${taskId}/param-preprocessing`); } function videoURLFromTask(task) { const data = Array.isArray(task.result?.data) ? task.result.data : []; return data.find((item) => item?.type === 'video')?.url || data.find((item) => item?.url)?.url || ''; } async function downloadVideo(url, filePath) { const response = await fetch(url); assert(response.ok, `Download ${url} failed ${response.status}`); const bytes = Buffer.from(await response.arrayBuffer()); assert(bytes.length > 0, `Downloaded video ${url} is empty`); await writeFile(filePath, bytes); return bytes.length; } function probeVideo(filePath) { const output = execFileSync( 'ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, ); return JSON.parse(output); } function validateRequestSnapshot(task, expectedRequest) { const snapshot = task.request || {}; for (const key of ['model', 'resolution', 'ratio', 'duration', 'generate_audio', 'seed']) { assert( snapshot[key] === expectedRequest[key], `Task ${task.id} request.${key}=${JSON.stringify(snapshot[key])}, expected ${JSON.stringify(expectedRequest[key])}`, ); } } function validateTaskAudit(task, events) { const attempts = Array.isArray(task.attempts) ? task.attempts : []; assert(attempts.length === 1, `Task ${task.id} has ${attempts.length} attempts, expected exactly one`); const attempt = attempts[0]; assert(attempt.platformName === expectedPlatform, `Task ${task.id} used platform ${attempt.platformName}, expected ${expectedPlatform}`); assert(attempt.provider === 'volces', `Task ${task.id} used provider ${attempt.provider}, expected volces`); assert( attempt.providerModelName === expectedProviderModel, `Task ${task.id} used provider model ${attempt.providerModelName}, expected ${expectedProviderModel}`, ); assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`); assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`); const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started'); assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`); assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`); return attempt; } function validateMedia(probe, expected, taskId) { const streams = Array.isArray(probe.streams) ? probe.streams : []; const video = streams.find((stream) => stream.codec_type === 'video'); const audioStreams = streams.filter((stream) => stream.codec_type === 'audio'); assert(video, `Task ${taskId} output has no video stream`); const width = Number(video.width); const height = Number(video.height); const duration = Number(video.duration || probe.format?.duration); assert(height === expected.height, `Task ${taskId} output height=${height}, expected ${expected.height}`); const ratioError = Math.abs(width / height - expected.ratio) / expected.ratio; assert(ratioError <= 0.02, `Task ${taskId} aspect ratio ${width}:${height} differs from 16:9 by ${(ratioError * 100).toFixed(2)}%`); assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`); assert(Math.abs(duration - expected.duration) <= 1, `Task ${taskId} duration=${duration}s, expected ${expected.duration}s ±1s`); if (expected.audio) { assert(audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`); const audioDuration = Number(audioStreams[0].duration || probe.format?.duration); if (Number.isFinite(audioDuration)) { assert(audioDuration >= duration - 1, `Task ${taskId} audio duration=${audioDuration}s is too short for video duration=${duration}s`); } } else { assert(audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${audioStreams.length} audio stream(s)`); } return { width, height, duration, ratio: width / height, videoCodec: video.codec_name || null, audioStreams: audioStreams.map((stream) => ({ codec: stream.codec_name || null, channels: Number(stream.channels || 0), sampleRate: Number(stream.sample_rate || 0), duration: Number(stream.duration || probe.format?.duration || 0), })), }; } const authentication = resolveGatewayAPIKey(); function sanitizedFailureMessage(error) { return String(error instanceof Error ? error.message : error) .replace(/Request id:\s*[A-Za-z0-9_-]+/gi, 'Request id: [redacted]') .replace(/\b\d{8,}\b/g, '[redacted-id]'); } async function writeFailureReport(error) { const report = { ok: false, generatedAt: new Date().toISOString(), baseURL, model, expectedPlatform, expectedProviderModel, authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId, }, submittedTaskIds, error: sanitizedFailureMessage(error), }; await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); return report; } async function main() { const tempDirectory = await mkdtemp(join(tmpdir(), 'deyun-video-e2e-')); const walletBefore = walletState(authentication.userId); const results = []; try { for (const validationCase of validationCases) { const taskId = await createVideoTask(validationCase.request); const task = await pollTask(taskId); const [events, preprocessing] = await Promise.all([ taskEvents(taskId), taskPreprocessingLogs(taskId), ]); validateRequestSnapshot(task, validationCase.request); const attempt = validateTaskAudit(task, events); assert( Array.isArray(preprocessing.items) && preprocessing.items.length > 0, `Task ${taskId} has no parameter preprocessing audit record`, ); const videoURL = videoURLFromTask(task); assert(videoURL, `Task ${taskId} result is missing a video URL`); const videoPath = join(tempDirectory, `${validationCase.name}.mp4`); const byteSize = await downloadVideo(videoURL, videoPath); const probe = probeVideo(videoPath); const observed = validateMedia(probe, validationCase.expected, taskId); results.push({ name: validationCase.name, taskId, remoteTaskId: task.remoteTaskId, requestId: attempt.requestId, platform: attempt.platformName, providerModelName: attempt.providerModelName, requested: { resolution: validationCase.request.resolution, ratio: validationCase.request.ratio, duration: validationCase.request.duration, generateAudio: validationCase.request.generate_audio, seed: validationCase.request.seed, }, observed, byteSize, finalChargeAmount: Number(task.finalChargeAmount), billingSummary: task.billingSummary || {}, eventTypes: events.map((event) => event.eventType), preprocessingChangeCount: preprocessing.items.reduce((sum, item) => sum + Number(item.changeCount || 0), 0), }); } } finally { await rm(tempDirectory, { recursive: true, force: true }); } const walletAfter = walletState(authentication.userId); const totalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0); const walletDebit = walletBefore.balance - walletAfter.balance; assert(nearlyEqual(walletDebit, totalCharge, 1e-6), `Wallet debit=${walletDebit}, expected total charge=${totalCharge}`); assert( nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance, 1e-6), `Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`, ); const report = { ok: true, generatedAt: new Date().toISOString(), baseURL, model, expectedPlatform, expectedProviderModel, authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId, }, wallet: { balanceBefore: walletBefore.balance, balanceAfter: walletAfter.balance, frozenBalanceBefore: walletBefore.frozenBalance, frozenBalanceAfter: walletAfter.frozenBalance, debit: walletDebit, totalCharge, }, results, }; await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); console.log(JSON.stringify({ ...report, outputPath }, null, 2)); } main().catch(async (error) => { const report = await writeFailureReport(error); console.error(report.error); console.error(`Failure report: ${outputPath}`); process.exitCode = 1; });