diff --git a/artifacts/deyun-video-e2e-production-20260717.json b/artifacts/deyun-video-e2e-production-20260717.json new file mode 100644 index 0000000..992506b --- /dev/null +++ b/artifacts/deyun-video-e2e-production-20260717.json @@ -0,0 +1,88 @@ +{ + "ok": true, + "generatedAt": "2026-07-17T06:19:48.217Z", + "baseURL": "https://ai.51easyai.com/gateway-api", + "release": "2026.07.17-2d6c16f", + "platform": { + "id": "81fa89a4-67c3-448b-9ee6-b6189f794b63", + "name": "漫路(火山兼容)", + "priority": 200 + }, + "model": { + "alias": "deyun-seedance-2.0-canary", + "providerModelName": "doubao-seedance-2-0" + }, + "authentication": { + "type": "temporary_gateway_api_key", + "keyId": "488fb516-235f-425d-b37a-d3d2bd071f98", + "deletedAfterTest": true + }, + "task": { + "id": "cab496fc-5fc0-4f83-a365-5787d649f0fb", + "status": "succeeded", + "remoteTaskId": "cgt-20260717141718-blxpt", + "requestId": "cgt-20260717141718-blxpt", + "attemptCount": 1, + "request": { + "model": "deyun-seedance-2.0-canary", + "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": 72017, + "watermark": false, + "runMode": "real" + }, + "finalChargeAmount": 200, + "billingSummary": { + "amountByCurrency": { + "resource": 200 + }, + "currency": "resource", + "finalCharge": { + "amount": 200, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 200 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingRecordCount": 1 + }, + "media": { + "byteSize": 3074928, + "width": 1280, + "height": 720, + "duration": 6.041667, + "ratio": 1.7777777777777777, + "videoCodec": "h264", + "audioStreams": [ + { + "codec": "aac", + "channels": 2, + "sampleRate": 44100, + "duration": 6.06 + } + ] + }, + "wallet": { + "balanceBefore": 99789, + "balanceAfter": 99589, + "frozenBefore": 0, + "frozenAfter": 0, + "debit": 200 + } +} diff --git a/artifacts/deyun-video-e2e-production-official-alias-20260717.json b/artifacts/deyun-video-e2e-production-official-alias-20260717.json new file mode 100644 index 0000000..716ab2f --- /dev/null +++ b/artifacts/deyun-video-e2e-production-official-alias-20260717.json @@ -0,0 +1,56 @@ +{ + "ok": true, + "generatedAt": "2026-07-17T06:28:51.689Z", + "model": { + "id": "118b4fac-d543-4344-ada0-71add153bfe6", + "modelAlias": "豆包Seedance-2.0", + "displayName": "豆包Seedance-2.0", + "providerModelName": "doubao-seedance-2-0" + }, + "platform": { + "id": "81fa89a4-67c3-448b-9ee6-b6189f794b63", + "name": "漫路(火山兼容)", + "priority": 200 + }, + "task": { + "id": "6b60099b-7275-4896-b4ee-db2c85bdbbad", + "status": "succeeded", + "remoteTaskId": "cgt-20260717142458-hmmdc", + "requestId": "cgt-20260717142458-hmmdc", + "attemptCount": 1, + "request": { + "duration": 4, + "generate_audio": false, + "model": "豆包Seedance-2.0", + "prompt": "A red paper airplane glides slowly across a bright blue studio background, locked camera.", + "ratio": "16:9", + "resolution": "720p", + "runMode": "real", + "seed": 72018, + "watermark": false + }, + "finalChargeAmount": 100 + }, + "media": { + "byteSize": 949084, + "width": 1280, + "height": 720, + "duration": 4.041667, + "ratio": 1.7777777777777777, + "videoCodec": "h264", + "audioStreamCount": 0 + }, + "taskWalletSettlement": { + "reserved": 100, + "released": 100, + "billed": 100, + "balanceBefore": 99589, + "balanceAfter": 99489 + }, + "accountSnapshot": { + "balance": 99339, + "frozenBalance": 0, + "note": "Current global frozen balance belongs to another concurrent task; this task reservation is fully released." + }, + "temporaryAPIKeysRemaining": 0 +} diff --git a/artifacts/keling-video-e2e-production-20260717.json b/artifacts/keling-video-e2e-production-20260717.json new file mode 100644 index 0000000..8dd107f --- /dev/null +++ b/artifacts/keling-video-e2e-production-20260717.json @@ -0,0 +1,45 @@ +{ + "ok": false, + "generatedAt": "2026-07-17T16:29:53.750Z", + "baseURL": "http://127.0.0.1:8088", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "authentication": { + "type": "gateway_api_key", + "source": "database", + "keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8" + }, + "selectedCases": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "interface": "kling-compatible", + "model": "kling-video-o1" + }, + { + "name": "compatible-v3-1080p-9x16-5s-audio-on", + "interface": "kling-compatible", + "model": "kling-v3-omni" + }, + { + "name": "standard-o1-1080p-9x16-5s-audio-on", + "interface": "gateway-standard", + "model": "kling-o1" + }, + { + "name": "standard-v3-720p-16x9-3s-audio-off", + "interface": "gateway-standard", + "model": "kling-3.0-omni" + } + ], + "submittedTasks": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "taskId": "7ec7bd46-e9be-4289-a3c7-ee1e662f71e8", + "model": "kling-video-o1" + } + ], + "error": "compatible-o1-720p-16x9-3s-audio-off failed, code=5000, message={\"msg\":\"该能力暂不支持\"}" +} diff --git a/artifacts/keling-video-e2e-production-fourth-case-20260717.json b/artifacts/keling-video-e2e-production-fourth-case-20260717.json new file mode 100644 index 0000000..ed87b5b --- /dev/null +++ b/artifacts/keling-video-e2e-production-fourth-case-20260717.json @@ -0,0 +1,100 @@ +{ + "ok": true, + "generatedAt": "2026-07-17T16:53:06.058Z", + "baseURL": "http://127.0.0.1:8088", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "platformModels": [ + { + "providerModelName": "kling-v3-omni", + "modelName": "kling-3.0-omni", + "modelAlias": "kling-3.0-omni", + "enabled": true + }, + { + "providerModelName": "kling-video-o1", + "modelName": "kling-o1", + "modelAlias": "kling-o1", + "enabled": true + } + ], + "authentication": { + "type": "gateway_api_key", + "source": "database", + "keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8" + }, + "wallet": { + "balanceBefore": 6515.735312, + "balanceAfter": 6435.735312, + "frozenBalanceBefore": 0.14133, + "frozenBalanceAfter": 0.14133, + "debit": 80, + "totalCharge": 80 + }, + "results": [ + { + "name": "standard-v3-720p-16x9-3s-audio-off", + "interface": "gateway-standard", + "taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe", + "remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e", + "requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "requestedModel": "kling-3.0-omni", + "providerModelName": "kling-v3-omni", + "requested": { + "resolution": "720p", + "aspectRatio": "16:9", + "duration": 3, + "audio": false + }, + "observed": { + "width": 1280, + "height": 720, + "shortEdge": 720, + "duration": 3.041667, + "ratio": 1.7777777777777777, + "ratioErrorPercent": 0, + "videoCodec": "h264", + "audioStreams": [], + "volume": null + }, + "byteSize": 1577848, + "attemptCount": 1, + "billingLineCount": 1, + "finalChargeAmount": 80, + "billingSummary": { + "amountByCurrency": { + "resource": 80 + }, + "currency": "resource", + "finalCharge": { + "amount": 80, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 80 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingChangeCount": 1 + } + ] +} diff --git a/artifacts/keling-video-e2e-production-official-models-20260717.json b/artifacts/keling-video-e2e-production-official-models-20260717.json new file mode 100644 index 0000000..aac225a --- /dev/null +++ b/artifacts/keling-video-e2e-production-official-models-20260717.json @@ -0,0 +1,45 @@ +{ + "ok": false, + "generatedAt": "2026-07-17T16:35:04.858Z", + "baseURL": "http://127.0.0.1:8088", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "authentication": { + "type": "gateway_api_key", + "source": "database", + "keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8" + }, + "selectedCases": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "interface": "kling-compatible", + "model": "kling-video-o1" + }, + { + "name": "compatible-v3-1080p-9x16-5s-audio-on", + "interface": "kling-compatible", + "model": "kling-v3-omni" + }, + { + "name": "standard-o1-1080p-9x16-5s-audio-on", + "interface": "gateway-standard", + "model": "kling-o1" + }, + { + "name": "standard-v3-720p-16x9-3s-audio-off", + "interface": "gateway-standard", + "model": "kling-3.0-omni" + } + ], + "submittedTasks": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "taskId": "abfadf1b-ad40-4eec-a38b-4a119b4861c7", + "model": "kling-video-o1" + } + ], + "error": "compatible-o1-720p-16x9-3s-audio-off failed, code=1201, message=Duration only support 5 or 10 seconds when no refer image" +} diff --git a/artifacts/keling-video-e2e-production-summary-20260717.json b/artifacts/keling-video-e2e-production-summary-20260717.json new file mode 100644 index 0000000..ea716c0 --- /dev/null +++ b/artifacts/keling-video-e2e-production-summary-20260717.json @@ -0,0 +1,302 @@ +{ + "ok": false, + "generatedAt": "2026-07-17T16:54:24.016Z", + "baseURL": "http://127.0.0.1:8088", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "platformModels": [ + { + "providerModelName": "kling-v3-omni", + "modelName": "kling-3.0-omni", + "modelAlias": "kling-3.0-omni", + "enabled": true + }, + { + "providerModelName": "kling-video-o1", + "modelName": "kling-o1", + "modelAlias": "kling-o1", + "enabled": true + } + ], + "authentication": { + "type": "gateway_api_key", + "source": "database", + "keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8" + }, + "wallet": { + "balanceBefore": 6435.735312, + "balanceAfter": 6435.735312, + "frozenBalanceBefore": 0.14133, + "frozenBalanceAfter": 0.14133, + "debit": 0, + "totalChargeThisRun": 0, + "totalHistoricalCharge": 520 + }, + "results": [ + { + "passed": true, + "name": "compatible-o1-720p-16x9-3s-audio-off", + "interface": "kling-compatible", + "taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb", + "remoteTaskId": "task_7dc690f0a77649a3b6f8949b9d003371", + "requestId": "67fd025f-0b53-4a56-8967-128f334b2bfb", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "requestedModel": "kling-video-o1", + "providerModelName": "kling-video-o1", + "reusedExistingTask": true, + "requested": { + "resolution": "720p", + "aspectRatio": "16:9", + "duration": 3, + "audio": false + }, + "observed": { + "width": 1280, + "height": 720, + "shortEdge": 720, + "duration": 3.041667, + "ratio": 1.7777777777777777, + "videoCodec": "h264", + "audioStreams": [], + "volume": null, + "ratioErrorPercent": 0 + }, + "byteSize": 1275483, + "attemptCount": 1, + "billingLineCount": 1, + "finalChargeAmount": 80, + "billingSummary": { + "amountByCurrency": { + "resource": 80 + }, + "currency": "resource", + "finalCharge": { + "amount": 80, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 80 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingChangeCount": 0 + }, + { + "passed": true, + "name": "compatible-v3-1080p-9x16-5s-audio-on", + "interface": "kling-compatible", + "taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28", + "remoteTaskId": "task_b4b622c374624c7e82cb34d6bc6c57af", + "requestId": "da16b141-44cc-4379-b463-35d06f2f0870", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "requestedModel": "kling-v3-omni", + "providerModelName": "kling-v3-omni", + "reusedExistingTask": true, + "requested": { + "resolution": "1080p", + "aspectRatio": "9:16", + "duration": 5, + "audio": true + }, + "observed": { + "width": 1080, + "height": 1920, + "shortEdge": 1080, + "duration": 5.041667, + "ratio": 0.5625, + "videoCodec": "h264", + "audioStreams": [ + { + "codec": "aac", + "channels": 2, + "sampleRate": 44100 + } + ], + "volume": { + "maxVolumeDb": -3.4, + "meanVolumeDb": -33.8 + }, + "ratioErrorPercent": 0 + }, + "byteSize": 8132835, + "attemptCount": 1, + "billingLineCount": 1, + "finalChargeAmount": 240, + "billingSummary": { + "amountByCurrency": { + "resource": 240 + }, + "currency": "resource", + "finalCharge": { + "amount": 240, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 240 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingChangeCount": 0 + }, + { + "passed": false, + "validationError": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream", + "name": "standard-o1-1080p-9x16-5s-audio-on", + "interface": "gateway-standard", + "taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45", + "remoteTaskId": "task_c0e8271648104c72956ea7cd6da80dea", + "requestId": "e4206343-6dbb-439b-94ba-ccc1f5a020a8", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "requestedModel": "kling-o1", + "providerModelName": "kling-video-o1", + "reusedExistingTask": true, + "requested": { + "resolution": "1080p", + "aspectRatio": "9:16", + "duration": 5, + "audio": true + }, + "observed": { + "width": 1080, + "height": 1920, + "shortEdge": 1080, + "duration": 5.041667, + "ratio": 0.5625, + "videoCodec": "h264", + "audioStreams": [], + "volume": null, + "ratioErrorPercent": 0 + }, + "byteSize": 12728988, + "attemptCount": 1, + "billingLineCount": 1, + "finalChargeAmount": 120, + "billingSummary": { + "amountByCurrency": { + "resource": 120 + }, + "currency": "resource", + "finalCharge": { + "amount": 120, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 120 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingChangeCount": 1 + }, + { + "passed": true, + "name": "standard-v3-720p-16x9-3s-audio-off", + "interface": "gateway-standard", + "taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe", + "remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e", + "requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "requestedModel": "kling-3.0-omni", + "providerModelName": "kling-v3-omni", + "reusedExistingTask": true, + "requested": { + "resolution": "720p", + "aspectRatio": "16:9", + "duration": 3, + "audio": false + }, + "observed": { + "width": 1280, + "height": 720, + "shortEdge": 720, + "duration": 3.041667, + "ratio": 1.7777777777777777, + "videoCodec": "h264", + "audioStreams": [], + "volume": null, + "ratioErrorPercent": 0 + }, + "byteSize": 1577848, + "attemptCount": 1, + "billingLineCount": 1, + "finalChargeAmount": 80, + "billingSummary": { + "amountByCurrency": { + "resource": 80 + }, + "currency": "resource", + "finalCharge": { + "amount": 80, + "currency": "resource", + "simulated": false + }, + "lineCount": 1, + "simulated": false, + "totalAmount": 80 + }, + "eventTypes": [ + "task.accepted", + "task.running", + "task.progress", + "task.attempt.started", + "task.progress", + "task.progress", + "task.progress", + "task.billing.settled", + "task.completed" + ], + "preprocessingChangeCount": 1 + } + ] +} diff --git a/artifacts/keling-video-e2e-production-validated-20260717.json b/artifacts/keling-video-e2e-production-validated-20260717.json new file mode 100644 index 0000000..bd3d6a6 --- /dev/null +++ b/artifacts/keling-video-e2e-production-validated-20260717.json @@ -0,0 +1,55 @@ +{ + "ok": false, + "generatedAt": "2026-07-17T16:49:49.164Z", + "baseURL": "http://127.0.0.1:8088", + "platform": { + "key": "transtreams_keling", + "name": "凌川 TranStreams(可灵)", + "provider": "keling" + }, + "authentication": { + "type": "gateway_api_key", + "source": "database", + "keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8" + }, + "selectedCases": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "interface": "kling-compatible", + "model": "kling-video-o1" + }, + { + "name": "compatible-v3-1080p-9x16-5s-audio-on", + "interface": "kling-compatible", + "model": "kling-v3-omni" + }, + { + "name": "standard-o1-1080p-9x16-5s-audio-on", + "interface": "gateway-standard", + "model": "kling-o1" + }, + { + "name": "standard-v3-720p-16x9-3s-audio-off", + "interface": "gateway-standard", + "model": "kling-3.0-omni" + } + ], + "submittedTasks": [ + { + "name": "compatible-o1-720p-16x9-3s-audio-off", + "taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb", + "model": "kling-video-o1" + }, + { + "name": "compatible-v3-1080p-9x16-5s-audio-on", + "taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28", + "model": "kling-v3-omni" + }, + { + "name": "standard-o1-1080p-9x16-5s-audio-on", + "taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45", + "model": "kling-o1" + } + ], + "error": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream" +} diff --git a/scripts/keling-video-e2e.mjs b/scripts/keling-video-e2e.mjs new file mode 100644 index 0000000..6bfd72f --- /dev/null +++ b/scripts/keling-video-e2e.mjs @@ -0,0 +1,634 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } 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 platformKey = process.env.KELING_VIDEO_PLATFORM_KEY || 'transtreams_keling'; +const pollIntervalMs = positiveInteger(process.env.KELING_VIDEO_POLL_INTERVAL_MS, 10_000); +const taskTimeoutMs = positiveInteger(process.env.KELING_VIDEO_TASK_TIMEOUT_MS, 30 * 60 * 1000); +const outputPath = + process.env.KELING_VIDEO_E2E_OUTPUT || + `artifacts/keling-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: 'compatible-o1-720p-16x9-3s-audio-off', + interface: 'kling-compatible', + requestedModel: 'kling-video-o1', + gatewayModel: 'kling-o1', + providerModel: 'kling-video-o1', + request: { + model_name: 'kling-video-o1', + prompt: 'A blue ceramic cup rotates slowly on a clean studio table, locked camera, no sound.', + mode: 'std', + aspect_ratio: '16:9', + duration: '3', + sound: 'off', + image_list: [ + { + image_url: 'https://placehold.co/1024x1024/png', + }, + ], + watermark_info: { enabled: false }, + external_task_id: 'gateway-keling-e2e-compatible-o1', + }, + expected: { resolution: '720p', shortEdge: 720, ratio: 16 / 9, duration: 3, audio: false }, + }, + { + name: 'compatible-v3-1080p-9x16-5s-audio-on', + interface: 'kling-compatible', + requestedModel: 'kling-v3-omni', + gatewayModel: 'kling-3.0-omni', + providerModel: 'kling-v3-omni', + request: { + model_name: 'kling-v3-omni', + prompt: 'Vertical close-up of gentle rain falling on green leaves, with clear natural rain ambience.', + mode: 'pro', + aspect_ratio: '9:16', + duration: 5, + sound: 'on', + watermark_info: { enabled: false }, + external_task_id: 'gateway-keling-e2e-compatible-v3', + }, + expected: { resolution: '1080p', shortEdge: 1080, ratio: 9 / 16, duration: 5, audio: true }, + }, + { + name: 'standard-o1-1080p-9x16-5s-audio-on', + interface: 'gateway-standard', + requestedModel: 'kling-o1', + gatewayModel: 'kling-o1', + providerModel: 'kling-video-o1', + request: { + model: 'kling-o1', + prompt: 'Vertical view of small ocean waves reaching a sandy beach, with audible natural surf.', + resolution: '1080p', + aspect_ratio: '9:16', + duration: 5, + audio: true, + watermark: false, + runMode: 'real', + }, + expected: { resolution: '1080p', shortEdge: 1080, ratio: 9 / 16, duration: 5, audio: true }, + }, + { + name: 'standard-v3-720p-16x9-3s-audio-off', + interface: 'gateway-standard', + requestedModel: 'kling-3.0-omni', + gatewayModel: 'kling-3.0-omni', + providerModel: 'kling-v3-omni', + request: { + model: 'kling-3.0-omni', + prompt: 'Wide shot of a paper windmill turning slowly beside a window, locked camera, no sound.', + resolution: '720p', + aspect_ratio: '16:9', + duration: 3, + audio: false, + watermark: false, + runMode: 'real', + }, + expected: { resolution: '720p', shortEdge: 720, ratio: 16 / 9, duration: 3, audio: false }, + }, +]; + +const selectedNames = new Set( + String(process.env.KELING_VIDEO_CASES || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +); +const resumeTaskIds = parseResumeTaskIds(process.env.KELING_VIDEO_RESUME_TASKS); +const continueOnValidationFailure = process.env.KELING_VIDEO_CONTINUE_ON_VALIDATION_FAILURE === 'true'; +if (selectedNames.size > 0) { + const selected = validationCases.filter((validationCase) => selectedNames.has(validationCase.name)); + if (selected.length !== selectedNames.size) { + throw new Error(`Unknown KELING_VIDEO_CASES value; known cases: ${validationCases.map((item) => item.name).join(', ')}`); + } + validationCases.splice(0, validationCases.length, ...selected); +} + +const submittedTasks = []; + +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 parseResumeTaskIds(raw) { + if (!String(raw || '').trim()) return {}; + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`KELING_VIDEO_RESUME_TASKS must be a JSON object: ${error.message}`); + } + assert(parsed && typeof parsed === 'object' && !Array.isArray(parsed), 'KELING_VIDEO_RESUME_TASKS must be a JSON object'); + return parsed; +} + +function nearlyEqual(left, right, tolerance = 1e-6) { + return Math.abs(Number(left) - Number(right)) <= tolerance; +} + +function sqlLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +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 preflightMediaTools() { + for (const command of ['ffprobe', 'ffmpeg']) { + const result = spawnSync(command, ['-version'], { encoding: 'utf8' }); + assert(result.status === 0, `${command} is required for Keling video acceptance`); + } +} + +function resolvePlatform() { + const fields = runPSQL(` +SELECT id::text, name, provider, status, base_url +FROM integration_platforms +WHERE platform_key = ${sqlLiteral(platformKey)} +LIMIT 1`).split('\t'); + assert(fields.length === 5 && fields[0], `Platform ${platformKey} was not found`); + assert(fields[2] === 'keling', `Platform ${platformKey} provider=${fields[2]}, expected keling`); + assert(fields[3] === 'enabled', `Platform ${platformKey} status=${fields[3]}, expected enabled`); + assert( + fields[4].replace(/\/+$/, '') === 'https://api-aigv.transtreams.com/kling/v1', + `Platform ${platformKey} base URL is not the configured TranStreams Kling endpoint`, + ); + return { id: fields[0], name: fields[1], provider: fields[2], status: fields[3] }; +} + +function validatePlatformModels(platformId) { + const output = runPSQL(` +SELECT provider_model_name, model_name, model_alias, enabled::text, model_type::text, capabilities::text +FROM platform_models +WHERE platform_id = ${sqlLiteral(platformId)}::uuid + AND model_alias IN ('kling-o1', 'kling-3.0-omni') +ORDER BY model_alias`); + const rows = output + .split(/\r?\n/) + .filter(Boolean) + .map((line) => line.split('\t')); + const expectedModels = new Map([ + ['kling-o1', 'kling-video-o1'], + ['kling-3.0-omni', 'kling-v3-omni'], + ]); + for (const [gatewayModel, providerModel] of expectedModels) { + const row = rows.find((candidate) => candidate[2] === gatewayModel); + assert(row, `Platform ${platformKey} is missing gateway model alias ${gatewayModel}`); + assert(row[0] === providerModel, `Platform model ${gatewayModel} provider_model_name=${row[0]}, expected ${providerModel}`); + assert(row[1] === gatewayModel, `Platform model ${gatewayModel} model_name=${row[1]}, expected ${gatewayModel}`); + assert(row[3] === 'true', `Platform model ${gatewayModel} is disabled`); + const modelTypes = JSON.parse(row[4]); + const capabilities = JSON.parse(row[5]); + assert(modelTypes.includes('omni_video'), `Platform model ${gatewayModel} must include omni_video`); + const omni = capabilities.omni_video || {}; + assert(Array.isArray(omni.output_resolutions), `Platform model ${gatewayModel} is missing omni_video.output_resolutions`); + assert(omni.output_resolutions.includes('720p'), `Platform model ${gatewayModel} does not advertise 720p`); + assert(omni.output_resolutions.includes('1080p'), `Platform model ${gatewayModel} does not advertise 1080p`); + const expectedOutputAudio = gatewayModel !== 'kling-o1'; + assert( + omni.output_audio === expectedOutputAudio, + `Platform model ${gatewayModel} omni_video.output_audio=${omni.output_audio}, expected ${expectedOutputAudio}`, + ); + } + return rows.map((row) => ({ providerModelName: row[0], modelName: row[1], modelAlias: row[2], enabled: row[3] === 'true' })); +} + +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'); + return { ...apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID), secret: process.env.GATEWAY_E2E_API_KEY, source: 'environment' }; + } + const fields = runPSQL(` +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`).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 fields = 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 = ${sqlLiteral(keyId)}::uuid +LIMIT 1`).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 fields = runPSQL(` +SELECT balance::float8, frozen_balance::float8 +FROM gateway_wallet_accounts +WHERE gateway_user_id = ${sqlLiteral(userId)}::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 gatewayRequest(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(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`${init.method || 'GET'} ${path} returned non-JSON HTTP ${response.status}`); + } + if (!response.ok) { + const code = body.code ?? body.error?.code ?? 'unknown'; + const message = body.message ?? body.error?.message ?? 'request failed'; + throw new Error(`${init.method || 'GET'} ${path} failed HTTP ${response.status}, code=${code}, message=${message}`); + } + return body; +} + +async function createTask(validationCase) { + let taskId = ''; + if (validationCase.interface === 'kling-compatible') { + const accepted = await gatewayRequest('/v1/videos/omni-video', { + method: 'POST', + body: JSON.stringify(validationCase.request), + }); + assert(accepted.code === 0, `${validationCase.name} compatibility create code=${accepted.code}`); + assert(accepted.data?.task_status === 'submitted', `${validationCase.name} was not submitted`); + taskId = accepted.data?.task_id; + } else { + const accepted = await gatewayRequest('/api/v1/videos/generations', { + method: 'POST', + headers: { 'X-Async': 'true' }, + body: JSON.stringify(validationCase.request), + }); + taskId = accepted.taskId || accepted.task?.id; + } + assert(taskId, `${validationCase.name} create response is missing a Gateway task ID`); + submittedTasks.push({ name: validationCase.name, taskId, model: validationCase.requestedModel, reusedExistingTask: false }); + return taskId; +} + +async function pollTask(validationCase, taskId) { + const startedAt = Date.now(); + while (Date.now() - startedAt < taskTimeoutMs) { + if (validationCase.interface === 'kling-compatible') { + const response = await gatewayRequest(`/v1/videos/omni-video/${taskId}`); + const status = response.data?.task_status; + if (status === 'succeed') break; + if (status === 'failed') { + throw new Error(`${validationCase.name} failed, code=${response.data?.task_status_code || 'unknown'}, message=${response.data?.task_status_msg || 'unknown'}`); + } + } else { + const task = await gatewayRequest(`/api/v1/tasks/${taskId}`); + if (task.status === 'succeeded') return task; + if (task.status === 'failed' || task.status === 'cancelled') { + throw new Error(`${validationCase.name} failed, code=${task.errorCode || 'unknown'}, message=${task.errorMessage || task.error || 'unknown'}`); + } + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + const task = await gatewayRequest(`/api/v1/tasks/${taskId}`); + if (task.status !== 'succeeded') { + throw new Error(`${validationCase.name} timed out after ${taskTimeoutMs}ms with status=${task.status}`); + } + return task; +} + +async function taskEvents(taskId) { + const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, { + headers: { Authorization: `Bearer ${authentication.secret}` }, + }); + const text = await response.text(); + assert(response.ok, `GET task events failed HTTP ${response.status}`); + 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 gatewayRequest(`/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, `Video download failed HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + assert(bytes.length > 0, 'Downloaded video is empty'); + await writeFile(filePath, bytes); + return bytes.length; +} + +function probeVideo(filePath) { + return JSON.parse( + execFileSync('ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }), + ); +} + +function probeAudioVolume(filePath) { + const result = spawnSync( + 'ffmpeg', + ['-hide_banner', '-nostats', '-i', filePath, '-map', '0:a:0', '-af', 'volumedetect', '-f', 'null', '-'], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + assert(result.status === 0, 'ffmpeg volume analysis failed'); + const diagnostics = `${result.stdout || ''}\n${result.stderr || ''}`; + const maxMatch = diagnostics.match(/max_volume:\s*(-?(?:\d+(?:\.\d+)?|inf))\s*dB/i); + const meanMatch = diagnostics.match(/mean_volume:\s*(-?(?:\d+(?:\.\d+)?|inf))\s*dB/i); + assert(maxMatch, 'ffmpeg did not report max_volume'); + const maxVolumeDb = Number(maxMatch[1]); + assert(Number.isFinite(maxVolumeDb), 'Audio stream is fully silent (max_volume=-inf)'); + return { maxVolumeDb, meanVolumeDb: meanMatch && Number.isFinite(Number(meanMatch[1])) ? Number(meanMatch[1]) : null }; +} + +function validateRequestSnapshots(task, validationCase) { + const request = task.request || {}; + assert(request.model === validationCase.gatewayModel, `Task ${task.id} request.model=${request.model}, expected ${validationCase.gatewayModel}`); + assert(request.resolution === validationCase.expected.resolution, `Task ${task.id} request.resolution=${request.resolution}`); + assert(request.aspect_ratio === validationCase.request.aspect_ratio, `Task ${task.id} request.aspect_ratio=${request.aspect_ratio}`); + assert(Number(request.duration) === validationCase.expected.duration, `Task ${task.id} request.duration=${request.duration}`); + assert(request.audio === validationCase.expected.audio, `Task ${task.id} request.audio=${request.audio}`); + if (validationCase.interface === 'kling-compatible') { + assert(request._gateway_compatibility === 'keling_omni_v1', `Task ${task.id} compatibility marker is missing`); + assert(request.external_task_id === validationCase.request.external_task_id, `Task ${task.id} external_task_id was not preserved`); + } +} + +function validateTaskAudit(task, events, validationCase) { + 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.status === 'succeeded', `Task ${task.id} attempt status=${attempt.status}`); + assert(attempt.platformName === platform.name, `Task ${task.id} used platform ${attempt.platformName}, expected ${platform.name}`); + assert(attempt.provider === 'keling', `Task ${task.id} used provider ${attempt.provider}, expected keling`); + assert( + attempt.providerModelName === validationCase.providerModel, + `Task ${task.id} providerModelName=${attempt.providerModelName}, expected ${validationCase.providerModel}`, + ); + assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`); + assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`); + const attemptRequest = attempt.requestSnapshot || {}; + assert(attemptRequest.model === validationCase.gatewayModel, `Task ${task.id} attempt request.model=${attemptRequest.model}`); + assert(attemptRequest.resolution === validationCase.expected.resolution, `Task ${task.id} attempt request.resolution=${attemptRequest.resolution}`); + assert(attemptRequest.aspect_ratio === validationCase.request.aspect_ratio, `Task ${task.id} attempt request.aspect_ratio=${attemptRequest.aspect_ratio}`); + assert(Number(attemptRequest.duration) === validationCase.expected.duration, `Task ${task.id} attempt request.duration=${attemptRequest.duration}`); + assert(attemptRequest.audio === validationCase.expected.audio, `Task ${task.id} attempt request.audio=${attemptRequest.audio}`); + assert(Array.isArray(task.billings) && task.billings.length > 0, `Task ${task.id} has no billing lines`); + assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`); + assert(nearlyEqual(task.billingSummary?.totalAmount, task.finalChargeAmount), `Task ${task.id} billing total does not match final charge`); + const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started'); + assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`); + return attempt; +} + +function inspectMedia(probe, filePath, 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); + const shortEdge = Math.min(width, height); + const ratio = width / height; + assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`); + const volume = audioStreams.length > 0 ? probeAudioVolume(filePath) : null; + return { + width, + height, + shortEdge, + duration, + ratio, + 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), + })), + volume, + }; +} + +function validateMedia(observed, expected, taskId) { + const ratioError = Math.abs(observed.ratio - expected.ratio) / expected.ratio; + observed.ratioErrorPercent = ratioError * 100; + assert(observed.shortEdge >= expected.shortEdge, `Task ${taskId} short edge=${observed.shortEdge}, expected at least ${expected.shortEdge}`); + assert(ratioError <= 0.01, `Task ${taskId} aspect ratio error=${(ratioError * 100).toFixed(2)}%, expected <=1%`); + assert(Math.abs(observed.duration - expected.duration) <= 1, `Task ${taskId} duration=${observed.duration}s, expected ${expected.duration}s ±1s`); + if (expected.audio) { + assert(observed.audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`); + assert(observed.volume && Number.isFinite(observed.volume.maxVolumeDb), `Task ${taskId} audio stream is fully silent`); + } else { + assert(observed.audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${observed.audioStreams.length} audio stream(s)`); + } +} + +function sanitizedFailureMessage(error) { + return String(error instanceof Error ? error.message : error) + .replace(/https?:\/\/[^\s,}]+/gi, '[redacted-url]') + .replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: Bearer [redacted]'); +} + +async function writeFailureReport(error) { + const report = { + ok: false, + generatedAt: new Date().toISOString(), + baseURL, + platform: { key: platformKey, name: platform?.name || null, provider: platform?.provider || null }, + authentication: authentication ? { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId } : null, + selectedCases: validationCases.map((item) => ({ name: item.name, interface: item.interface, model: item.requestedModel })), + submittedTasks, + error: sanitizedFailureMessage(error), + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + return report; +} + +let platform; +let authentication; + +async function main() { + preflightMediaTools(); + platform = resolvePlatform(); + const platformModels = validatePlatformModels(platform.id); + authentication = resolveGatewayAPIKey(); + const health = await fetch(`${baseURL}/healthz`); + assert(health.ok, `Gateway health check failed HTTP ${health.status}`); + + const walletBefore = walletState(authentication.userId); + const tempDirectory = await mkdtemp(join(tmpdir(), 'keling-video-e2e-')); + const results = []; + try { + for (const validationCase of validationCases) { + const resumedTaskId = String(resumeTaskIds[validationCase.name] || '').trim(); + let taskId; + if (resumedTaskId) { + taskId = resumedTaskId; + submittedTasks.push({ + name: validationCase.name, + taskId, + model: validationCase.requestedModel, + reusedExistingTask: true, + }); + console.error(`Revalidating existing task for ${validationCase.name}`); + } else { + console.error(`Submitting ${validationCase.name} exactly once`); + taskId = await createTask(validationCase); + } + const task = await pollTask(validationCase, taskId); + const [events, preprocessing] = await Promise.all([taskEvents(taskId), taskPreprocessingLogs(taskId)]); + validateRequestSnapshots(task, validationCase); + const attempt = validateTaskAudit(task, events, validationCase); + assert(Array.isArray(preprocessing.items), `Task ${taskId} preprocessing audit is unavailable`); + 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 observed = inspectMedia(probeVideo(videoPath), videoPath, taskId); + let validationError = ''; + try { + validateMedia(observed, validationCase.expected, taskId); + } catch (error) { + validationError = sanitizedFailureMessage(error); + if (!continueOnValidationFailure) throw error; + } + results.push({ + passed: validationError === '', + validationError: validationError || undefined, + name: validationCase.name, + interface: validationCase.interface, + taskId, + remoteTaskId: task.remoteTaskId, + requestId: attempt.requestId, + platform: { key: platformKey, name: attempt.platformName, provider: attempt.provider }, + requestedModel: validationCase.requestedModel, + providerModelName: attempt.providerModelName, + reusedExistingTask: Boolean(resumedTaskId), + requested: { + resolution: validationCase.expected.resolution, + aspectRatio: validationCase.request.aspect_ratio, + duration: validationCase.expected.duration, + audio: validationCase.expected.audio, + }, + observed, + byteSize, + attemptCount: task.attemptCount, + billingLineCount: task.billings.length, + 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 totalHistoricalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0); + const totalChargeThisRun = results + .filter((result) => !result.reusedExistingTask) + .reduce((sum, result) => sum + result.finalChargeAmount, 0); + const walletDebit = walletBefore.balance - walletAfter.balance; + assert(nearlyEqual(walletDebit, totalChargeThisRun), `Wallet debit=${walletDebit}, expected this-run charge=${totalChargeThisRun}`); + assert( + nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance), + `Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`, + ); + + const report = { + ok: results.every((result) => result.passed), + generatedAt: new Date().toISOString(), + baseURL, + platform: { key: platformKey, name: platform.name, provider: platform.provider }, + platformModels, + 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, + totalChargeThisRun, + totalHistoricalCharge, + }, + 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)); + if (!report.ok) process.exitCode = 1; +} + +main().catch(async (error) => { + const report = await writeFailureReport(error); + console.error(report.error); + console.error(`Failure report: ${outputPath}`); + process.exitCode = 1; +});