kling-adapter.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * Kling (Kuaishou) Video Generation Adapter
  3. *
  4. * Async task pattern: submit → poll → return video URL.
  5. *
  6. * REST endpoints:
  7. * - Submit: POST /v1/videos/text2video
  8. * - Poll: GET /v1/videos/text2video/{task_id}
  9. *
  10. * Authentication: JWT Bearer token generated from Access Key + Secret Key.
  11. * The apiKey field should be formatted as "accessKey:secretKey".
  12. *
  13. * Supported models:
  14. * - kling-v2-6 (latest)
  15. * - kling-v1-6 (v1)
  16. *
  17. * API docs: https://docs.klingai.com/api
  18. */
  19. import crypto from 'crypto';
  20. import type {
  21. VideoGenerationConfig,
  22. VideoGenerationOptions,
  23. VideoGenerationResult,
  24. } from '../types';
  25. const DEFAULT_MODEL = 'kling-v2-6';
  26. const DEFAULT_BASE_URL = 'https://api-beijing.klingai.com';
  27. const POLL_INTERVAL_MS = 5_000;
  28. const MAX_POLL_ATTEMPTS = 120; // 10 minutes max
  29. const JWT_EXPIRY_SECS = 1800; // 30 minutes
  30. // ---------------------------------------------------------------------------
  31. // JWT helper (HS256, no external deps)
  32. // ---------------------------------------------------------------------------
  33. function base64url(data: Buffer | string): string {
  34. const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf-8');
  35. return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
  36. }
  37. function generateJWT(accessKey: string, secretKey: string): string {
  38. const now = Math.floor(Date.now() / 1000);
  39. const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  40. const payload = base64url(
  41. JSON.stringify({
  42. iss: accessKey,
  43. exp: now + JWT_EXPIRY_SECS,
  44. nbf: now - 5,
  45. iat: now,
  46. }),
  47. );
  48. const signature = base64url(
  49. crypto.createHmac('sha256', secretKey).update(`${header}.${payload}`).digest(),
  50. );
  51. return `${header}.${payload}.${signature}`;
  52. }
  53. function parseApiKey(apiKey: string): { accessKey: string; secretKey: string } {
  54. const sep = apiKey.indexOf(':');
  55. if (sep <= 0) {
  56. throw new Error('Kling apiKey must be "accessKey:secretKey" format');
  57. }
  58. return {
  59. accessKey: apiKey.slice(0, sep),
  60. secretKey: apiKey.slice(sep + 1),
  61. };
  62. }
  63. // ---------------------------------------------------------------------------
  64. // REST types
  65. // ---------------------------------------------------------------------------
  66. interface KlingSubmitResponse {
  67. code: number;
  68. message: string;
  69. data: {
  70. task_id: string;
  71. task_status: string;
  72. };
  73. }
  74. interface KlingPollResponse {
  75. code: number;
  76. message: string;
  77. data: {
  78. task_id: string;
  79. task_status: string; // submitted | processing | succeed | failed
  80. task_status_msg?: string;
  81. task_result?: {
  82. videos?: Array<{
  83. id: string;
  84. url: string;
  85. duration: string; // seconds as string
  86. }>;
  87. };
  88. };
  89. }
  90. // ---------------------------------------------------------------------------
  91. // Dimension helpers
  92. // ---------------------------------------------------------------------------
  93. function getDimensions(aspectRatio?: string): {
  94. width: number;
  95. height: number;
  96. } {
  97. switch (aspectRatio) {
  98. case '9:16':
  99. return { width: 720, height: 1280 };
  100. case '1:1':
  101. return { width: 1080, height: 1080 };
  102. case '4:3':
  103. return { width: 1024, height: 768 };
  104. default:
  105. return { width: 1280, height: 720 }; // 16:9
  106. }
  107. }
  108. /**
  109. * Lightweight connectivity test — validates API key by generating a JWT
  110. * and making a GET request. 401/403 means key invalid.
  111. */
  112. export async function testKlingConnectivity(
  113. config: VideoGenerationConfig,
  114. ): Promise<{ success: boolean; message: string }> {
  115. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  116. try {
  117. const { accessKey, secretKey } = parseApiKey(config.apiKey);
  118. const token = generateJWT(accessKey, secretKey);
  119. // Use a GET to a non-existent task to validate auth
  120. const response = await fetch(`${baseUrl}/v1/videos/text2video/connectivity-test`, {
  121. method: 'GET',
  122. headers: { Authorization: `Bearer ${token}` },
  123. });
  124. if (response.status === 401 || response.status === 403) {
  125. const text = await response.text();
  126. return {
  127. success: false,
  128. message: `Kling auth failed (${response.status}): ${text}`,
  129. };
  130. }
  131. return { success: true, message: 'Connected to Kling' };
  132. } catch (err) {
  133. return { success: false, message: `Kling connectivity error: ${err}` };
  134. }
  135. }
  136. // ---------------------------------------------------------------------------
  137. // Submit
  138. // ---------------------------------------------------------------------------
  139. async function submitTask(
  140. baseUrl: string,
  141. token: string,
  142. model: string,
  143. options: VideoGenerationOptions,
  144. ): Promise<string> {
  145. const body: Record<string, unknown> = {
  146. model_name: model,
  147. prompt: options.prompt,
  148. negative_prompt: '',
  149. mode: 'pro',
  150. };
  151. if (options.duration) body.duration = String(options.duration);
  152. if (options.aspectRatio) body.aspect_ratio = options.aspectRatio;
  153. const response = await fetch(`${baseUrl}/v1/videos/text2video`, {
  154. method: 'POST',
  155. headers: {
  156. 'Content-Type': 'application/json',
  157. Authorization: `Bearer ${token}`,
  158. },
  159. body: JSON.stringify(body),
  160. });
  161. if (!response.ok) {
  162. const text = await response.text();
  163. throw new Error(`Kling submit failed (${response.status}): ${text}`);
  164. }
  165. const data = (await response.json()) as KlingSubmitResponse;
  166. if (data.code !== 0) {
  167. throw new Error(`Kling submit error ${data.code}: ${data.message}`);
  168. }
  169. if (!data.data?.task_id) {
  170. throw new Error('Kling returned empty task_id');
  171. }
  172. return data.data.task_id;
  173. }
  174. // ---------------------------------------------------------------------------
  175. // Poll
  176. // ---------------------------------------------------------------------------
  177. async function pollTask(
  178. baseUrl: string,
  179. token: string,
  180. taskId: string,
  181. ): Promise<KlingPollResponse['data']> {
  182. const response = await fetch(`${baseUrl}/v1/videos/text2video/${taskId}`, {
  183. method: 'GET',
  184. headers: { Authorization: `Bearer ${token}` },
  185. });
  186. if (!response.ok) {
  187. const text = await response.text();
  188. throw new Error(`Kling poll failed (${response.status}): ${text}`);
  189. }
  190. const data = (await response.json()) as KlingPollResponse;
  191. if (data.code !== 0) {
  192. throw new Error(`Kling poll error ${data.code}: ${data.message}`);
  193. }
  194. return data.data;
  195. }
  196. // ---------------------------------------------------------------------------
  197. // Public entry point
  198. // ---------------------------------------------------------------------------
  199. export async function generateWithKling(
  200. config: VideoGenerationConfig,
  201. options: VideoGenerationOptions,
  202. ): Promise<VideoGenerationResult> {
  203. const model = config.model || DEFAULT_MODEL;
  204. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  205. const { accessKey, secretKey } = parseApiKey(config.apiKey);
  206. const token = generateJWT(accessKey, secretKey);
  207. // 1. Submit
  208. const taskId = await submitTask(baseUrl, token, model, options);
  209. // 2. Poll until done
  210. for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
  211. await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
  212. const result = await pollTask(baseUrl, token, taskId);
  213. if (result.task_status === 'succeed') {
  214. const video = result.task_result?.videos?.[0];
  215. if (!video?.url) {
  216. throw new Error('Kling task succeeded but no video URL returned');
  217. }
  218. const { width, height } = getDimensions(options.aspectRatio);
  219. return {
  220. url: video.url,
  221. duration: Number(video.duration) || options.duration || 5,
  222. width,
  223. height,
  224. };
  225. }
  226. if (result.task_status === 'failed') {
  227. throw new Error(
  228. `Kling video generation failed: ${result.task_status_msg || 'Unknown error'}`,
  229. );
  230. }
  231. }
  232. throw new Error(
  233. `Kling video generation timed out after ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s (task: ${taskId})`,
  234. );
  235. }