Z.AI: Add video generation models
| @@ -2297,7 +2297,7 @@ async function loadGoogleModels() { | |||
| 2297 | } | 2297 | } |
| 2298 | 2298 | ||
| 2299 | async function loadZaiModels() { | 2299 | async function loadZaiModels() { |
| 2300 | return ['cogview-4-250304'].map(name => ({ value: name, text: name })); | 2300 | return ['cogview-4-250304', 'cogvideox-3', 'viduq1-text'].map(name => ({ value: name, text: name })); |
| 2301 | } | 2301 | } |
| 2302 | 2302 | ||
| 2303 | async function loadOpenRouterModels() { | 2303 | async function loadOpenRouterModels() { |
| @@ -3274,7 +3274,7 @@ async function generateExtrasImage(prompt, negativePrompt, signal) { | |||
| 3274 | * Gets an aspect ratio for Stability that is the closest to the given width and height. | 3274 | * Gets an aspect ratio for Stability that is the closest to the given width and height. |
| 3275 | * @param {number} width Target width | 3275 | * @param {number} width Target width |
| 3276 | * @param {number} height Target height | 3276 | * @param {number} height Target height |
| 3277 | * @param {'google'|'stability'} source Source of the request, used to determine aspect ratio | 3277 | * @param {'google'|'stability'|'zai'} source Source of the request, used to determine aspect ratio |
| 3278 | * @returns {string} Closest aspect ratio as a string | 3278 | * @returns {string} Closest aspect ratio as a string |
| 3279 | */ | 3279 | */ |
| 3280 | function getClosestAspectRatio(width, height, source) { | 3280 | function getClosestAspectRatio(width, height, source) { |
| @@ -3300,6 +3300,12 @@ function getClosestAspectRatio(width, height, source) { | |||
| 3300 | '4:3': 4 / 3, | 3300 | '4:3': 4 / 3, |
| 3301 | '3:4': 3 / 4, | 3301 | '3:4': 3 / 4, |
| 3302 | }; | 3302 | }; |
| 3303 | case 'zai': | ||
| 3304 | return { | ||
| 3305 | '1:1': 1, | ||
| 3306 | '16:9': 16 / 9, | ||
| 3307 | '9:16': 9 / 16, | ||
| 3308 | }; | ||
| 3303 | default: | 3309 | default: |
| 3304 | console.warn(`Unknown source "${source}" for aspect ratio calculation.`); | 3310 | console.warn(`Unknown source "${source}" for aspect ratio calculation.`); |
| 3305 | return null; | 3311 | return null; |
| @@ -3328,9 +3334,15 @@ function getClosestAspectRatio(width, height, source) { | |||
| 3328 | * Get closest size for Electron Hub | 3334 | * Get closest size for Electron Hub |
| 3329 | * @param {number} width - The width of the image | 3335 | * @param {number} width - The width of the image |
| 3330 | * @param {number} height - The height of the image | 3336 | * @param {number} height - The height of the image |
| 3337 | * @param {string[]} sizes - Available sizes | ||
| 3331 | * @returns {Promise<string>} - The closest size | 3338 | * @returns {Promise<string>} - The closest size |
| 3332 | */ | 3339 | */ |
| 3333 | async function getClosestSize(width, height) { | 3340 | async function getClosestSize(width, height, sizes = []) { |
| 3341 | const sizesData = []; | ||
| 3342 | |||
| 3343 | if (Array.isArray(sizes) && sizes.length > 0) { | ||
| 3344 | sizesData.push(...sizes); | ||
| 3345 | } else if (extension_settings.sd.source === sources.electronhub) { | ||
| 3334 | const response = await fetch('/api/sd/electronhub/sizes', { | 3346 | const response = await fetch('/api/sd/electronhub/sizes', { |
| 3335 | method: 'POST', | 3347 | method: 'POST', |
| 3336 | headers: getRequestHeaders(), | 3348 | headers: getRequestHeaders(), |
| @@ -3343,7 +3355,20 @@ async function getClosestSize(width, height) { | |||
| 3343 | throw new Error(text); | 3355 | throw new Error(text); |
| 3344 | } | 3356 | } |
| 3345 | const result = await response.json(); | 3357 | const result = await response.json(); |
| 3346 | const sizesData = result.sizes; | 3358 | sizesData.push(...result.sizes); |
| 3359 | } else { | ||
| 3360 | return null; | ||
| 3361 | } | ||
| 3362 | |||
| 3363 | const targetWidth = Number(width); | ||
| 3364 | const targetHeight = Number(height); | ||
| 3365 | |||
| 3366 | if (isNaN(targetWidth) || isNaN(targetHeight)) { | ||
| 3367 | return null; | ||
| 3368 | } | ||
| 3369 | |||
| 3370 | const targetAspect = targetWidth / targetHeight; | ||
| 3371 | const targetResolution = targetWidth * targetHeight; | ||
| 3347 | 3372 | ||
| 3348 | const closestSize = sizesData.reduce((closest, size) => { | 3373 | const closestSize = sizesData.reduce((closest, size) => { |
| 3349 | if (!size || typeof size !== 'string') { | 3374 | if (!size || typeof size !== 'string') { |
| @@ -3356,16 +3381,14 @@ async function getClosestSize(width, height) { | |||
| 3356 | 3381 | ||
| 3357 | const sizeWidth = Number(sizeParts[0]); | 3382 | const sizeWidth = Number(sizeParts[0]); |
| 3358 | const sizeHeight = Number(sizeParts[1]); | 3383 | const sizeHeight = Number(sizeParts[1]); |
| 3359 | const targetWidth = Number(width); | ||
| 3360 | const targetHeight = Number(height); | ||
| 3361 | 3384 | ||
| 3362 | if (isNaN(sizeWidth) || isNaN(sizeHeight) || isNaN(targetWidth) || isNaN(targetHeight)) { | 3385 | if (isNaN(sizeWidth) || isNaN(sizeHeight)) { |
| 3363 | return closest; | 3386 | return closest; |
| 3364 | } | 3387 | } |
| 3365 | 3388 | ||
| 3366 | const sizeArea = sizeWidth * sizeHeight; | 3389 | const aspectDiff = Math.abs((sizeWidth / sizeHeight) - targetAspect) / targetAspect; |
| 3367 | const targetArea = targetWidth * targetHeight; | 3390 | const resolutionDiff = Math.abs(sizeWidth * sizeHeight - targetResolution) / targetResolution; |
| 3368 | const diff = Math.abs(sizeArea - targetArea); | 3391 | const diff = aspectDiff + resolutionDiff; |
| 3369 | 3392 | ||
| 3370 | return diff < closest.diff ? { size, diff } : closest; | 3393 | return diff < closest.diff ? { size, diff } : closest; |
| 3371 | }, { size: null, diff: Infinity }); | 3394 | }, { size: null, diff: Infinity }); |
| @@ -4245,6 +4268,36 @@ async function generateGoogleImage(prompt, negativePrompt, signal) { | |||
| 4245 | * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete. | 4268 | * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete. |
| 4246 | */ | 4269 | */ |
| 4247 | async function generateZaiImage(prompt, signal) { | 4270 | async function generateZaiImage(prompt, signal) { |
| 4271 | if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) { | ||
| 4272 | const videoParams = {}; | ||
| 4273 | if (/cogvideox/.test(extension_settings.sd.model)) { | ||
| 4274 | const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160']; | ||
| 4275 | videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed'; | ||
| 4276 | videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes); | ||
| 4277 | } | ||
| 4278 | if (/vidu/.test(extension_settings.sd.model)) { | ||
| 4279 | videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai'); | ||
| 4280 | } | ||
| 4281 | |||
| 4282 | const videoResult = await fetch('/api/sd/zai/generate-video', { | ||
| 4283 | method: 'POST', | ||
| 4284 | headers: getRequestHeaders(), | ||
| 4285 | signal: signal, | ||
| 4286 | body: JSON.stringify({ | ||
| 4287 | prompt: prompt, | ||
| 4288 | model: extension_settings.sd.model, | ||
| 4289 | ...videoParams, | ||
| 4290 | }), | ||
| 4291 | }); | ||
| 4292 | |||
| 4293 | if (videoResult.ok) { | ||
| 4294 | const data = await videoResult.json(); | ||
| 4295 | return { format: data.format, data: data.video }; | ||
| 4296 | } | ||
| 4297 | |||
| 4298 | const text = await videoResult.text(); | ||
| 4299 | throw new Error(text); | ||
| 4300 | } else { | ||
| 4248 | // Round width and height to nearest multiple of 16, and clamp to 512-2048 range | 4301 | // Round width and height to nearest multiple of 16, and clamp to 512-2048 range |
| 4249 | let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048); | 4302 | let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048); |
| 4250 | let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048); | 4303 | let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048); |
| @@ -4278,6 +4331,7 @@ async function generateZaiImage(prompt, signal) { | |||
| 4278 | const text = await result.text(); | 4331 | const text = await result.text(); |
| 4279 | throw new Error(text); | 4332 | throw new Error(text); |
| 4280 | } | 4333 | } |
| 4334 | } | ||
| 4281 | 4335 | ||
| 4282 | /** | 4336 | /** |
| 4283 | * Generates an image using the OpenRouter API. | 4337 | * Generates an image using the OpenRouter API. |
| @@ -187,7 +187,7 @@ | |||
| 187 | <option value="high" data-i18n="High">High</option> | 187 | <option value="high" data-i18n="High">High</option> |
| 188 | </select> | 188 | </select> |
| 189 | </div> | 189 | </div> |
| 190 | <div data-sd-model="dall-e-3,cogview-4" class="flex1"> | 190 | <div data-sd-model="dall-e-3,cogview-4,cogvideox" class="flex1"> |
| 191 | <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label> | 191 | <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label> |
| 192 | <select id="sd_openai_quality"> | 192 | <select id="sd_openai_quality"> |
| 193 | <option value="standard" data-i18n="Standard">Standard</option> | 193 | <option value="standard" data-i18n="Standard">Standard</option> |
| @@ -1805,6 +1805,107 @@ zai.post('/generate', async (request, response) => { | |||
| 1805 | } | 1805 | } |
| 1806 | }); | 1806 | }); |
| 1807 | 1807 | ||
| 1808 | zai.post('/generate-video', async (request, response) => { | ||
| 1809 | try { | ||
| 1810 | const controller = new AbortController(); | ||
| 1811 | request.socket.removeAllListeners('close'); | ||
| 1812 | request.socket.on('close', function () { | ||
| 1813 | controller.abort(); | ||
| 1814 | }); | ||
| 1815 | |||
| 1816 | const key = readSecret(request.user.directories, SECRET_KEYS.ZAI); | ||
| 1817 | |||
| 1818 | if (!key) { | ||
| 1819 | console.warn('Z.AI key not found.'); | ||
| 1820 | return response.sendStatus(400); | ||
| 1821 | } | ||
| 1822 | |||
| 1823 | console.debug('Z.AI video request:', request.body); | ||
| 1824 | |||
| 1825 | const generateResponse = await fetch('https://api.z.ai/api/paas/v4/videos/generations', { | ||
| 1826 | method: 'POST', | ||
| 1827 | headers: { | ||
| 1828 | 'Content-Type': 'application/json', | ||
| 1829 | 'Authorization': `Bearer ${key}`, | ||
| 1830 | }, | ||
| 1831 | body: JSON.stringify({ | ||
| 1832 | prompt: request.body.prompt, | ||
| 1833 | model: request.body.model, | ||
| 1834 | quality: request.body.quality, | ||
| 1835 | size: request.body.size, | ||
| 1836 | aspect_ratio: request.body.aspect_ratio, | ||
| 1837 | }), | ||
| 1838 | signal: controller.signal, | ||
| 1839 | }); | ||
| 1840 | |||
| 1841 | if (!generateResponse.ok) { | ||
| 1842 | const text = await generateResponse.text(); | ||
| 1843 | console.warn('Z.AI returned an error.', text); | ||
| 1844 | return response.sendStatus(500); | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | /** @type {any} */ | ||
| 1848 | const data = await generateResponse.json(); | ||
| 1849 | console.debug('Z.AI video response:', data); | ||
| 1850 | |||
| 1851 | // Poll for video generation completion | ||
| 1852 | for (let attempt = 0; attempt < 30; attempt++) { | ||
| 1853 | if (controller.signal.aborted) { | ||
| 1854 | console.info('Z.AI video generation aborted by client'); | ||
| 1855 | return response.status(500).send('Video generation aborted by client'); | ||
| 1856 | } | ||
| 1857 | |||
| 1858 | await delay(5000 + attempt * 1000); | ||
| 1859 | console.debug(`Polling Z.AI video job ${data.id}, attempt ${attempt + 1}`); | ||
| 1860 | |||
| 1861 | const pollResponse = await fetch(`https://api.z.ai/api/paas/v4/async-result/${data.id}`, { | ||
| 1862 | method: 'GET', | ||
| 1863 | headers: { | ||
| 1864 | 'Authorization': `Bearer ${key}`, | ||
| 1865 | }, | ||
| 1866 | }); | ||
| 1867 | |||
| 1868 | if (!pollResponse.ok) { | ||
| 1869 | const text = await pollResponse.text(); | ||
| 1870 | console.warn('Z.AI video job polling failed', pollResponse.statusText, text); | ||
| 1871 | return response.status(500).send(text); | ||
| 1872 | } | ||
| 1873 | |||
| 1874 | /** @type {any} */ | ||
| 1875 | const pollResult = await pollResponse.json(); | ||
| 1876 | console.debug(`Z.AI video job status: ${pollResult.task_status}`); | ||
| 1877 | |||
| 1878 | if (pollResult.task_status === 'FAIL') { | ||
| 1879 | console.warn('Z.AI video generation failed', pollResult); | ||
| 1880 | return response.status(500).send('Video generation failed'); | ||
| 1881 | } | ||
| 1882 | |||
| 1883 | if (pollResult.task_status === 'SUCCESS') { | ||
| 1884 | console.debug('Z.AI video generation succeeded', pollResult); | ||
| 1885 | const url = pollResult?.video_result?.[0]?.url; | ||
| 1886 | |||
| 1887 | if (!url || !isValidUrl(url)) { | ||
| 1888 | console.warn('Z.AI returned an invalid video URL.'); | ||
| 1889 | return response.sendStatus(500); | ||
| 1890 | } | ||
| 1891 | |||
| 1892 | const contentResponse = await fetch(url); | ||
| 1893 | if (!contentResponse.ok) { | ||
| 1894 | const text = await contentResponse.text(); | ||
| 1895 | console.warn('Z.AI video content fetch failed', contentResponse.statusText, text); | ||
| 1896 | return response.status(500).send(text); | ||
| 1897 | } | ||
| 1898 | |||
| 1899 | const contentBuffer = await contentResponse.arrayBuffer(); | ||
| 1900 | return response.send({ format: 'mp4', video: Buffer.from(contentBuffer).toString('base64') }); | ||
| 1901 | } | ||
| 1902 | } | ||
| 1903 | } catch (error) { | ||
| 1904 | console.error(error); | ||
| 1905 | return response.sendStatus(500); | ||
| 1906 | } | ||
| 1907 | }); | ||
| 1908 | |||
| 1808 | router.use('/comfy', comfy); | 1909 | router.use('/comfy', comfy); |
| 1809 | router.use('/comfyrunpod', comfyRunPod); | 1910 | router.use('/comfyrunpod', comfyRunPod); |
| 1810 | router.use('/together', together); | 1911 | router.use('/together', together); |