Z.AI: Add video generation models
| @@ -2297,7 +2297,7 @@ async function loadGoogleModels() { | ||
| 2297 | 2297 | } |
| 2298 | 2298 | |
| 2299 | 2299 | async function loadZaiModels() { |
| 2300 | 2300 | return ['cogview-4-250304', 'cogvideox-3', 'viduq1-text'].map(name => ({ value: name, text: name })); |
| 2301 | 2301 | } |
| 2302 | 2302 | |
| 2303 | 2303 | async function loadOpenRouterModels() { |
| @@ -3274,7 +3274,7 @@ async function generateExtrasImage(prompt, negativePrompt, signal) { | ||
| 3274 | 3274 | * Gets an aspect ratio for Stability that is the closest to the given width and height. |
| 3275 | 3275 | * @param {number} width Target width |
| 3276 | 3276 | * @param {number} height Target height |
| 3277 | 3277 | * @param {'google'|'stability'|'zai'} source Source of the request, used to determine aspect ratio |
| 3278 | 3278 | * @returns {string} Closest aspect ratio as a string |
| 3279 | 3279 | */ |
| 3280 | 3280 | function getClosestAspectRatio(width, height, source) { |
| @@ -3300,6 +3300,12 @@ function getClosestAspectRatio(width, height, source) { | ||
| 3300 | 3300 | '4:3': 4 / 3, |
| 3301 | 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 | 3309 | default: |
| 3304 | 3310 | console.warn(`Unknown source "${source}" for aspect ratio calculation.`); |
| 3305 | 3311 | return null; |
| @@ -3328,22 +3334,41 @@ function getClosestAspectRatio(width, height, source) { | ||
| 3328 | 3334 | * Get closest size for Electron Hub |
| 3329 | 3335 | * @param {number} width - The width of the image |
| 3330 | 3336 | * @param {number} height - The height of the image |
| 3337 | + * @param {string[]} sizes - Available sizes | |
| 3331 | 3338 | * @returns {Promise<string>} - The closest size |
| 3332 | 3339 | */ |
| 3333 | 3340 | async function getClosestSize(width, height, sizes = []) { |
| 3334 | - const response = await fetch('/api/sd/electronhub/sizes', { | |
| 3341 | + const sizesData = []; | |
| 3335 | - method: 'POST', | |
| 3342 | + | |
| 3336 | - headers: getRequestHeaders(), | |
| 3343 | + if (Array.isArray(sizes) && sizes.length > 0) { | |
| 3337 | - body: JSON.stringify({ | |
| 3344 | + sizesData.push(...sizes); | |
| 3338 | - model: extension_settings.sd.model, | |
| 3345 | + } else if (extension_settings.sd.source === sources.electronhub) { | |
| 3339 | - }), | |
| 3346 | + const response = await fetch('/api/sd/electronhub/sizes', { | |
| 3340 | - }); | |
| 3347 | + method: 'POST', | |
| 3341 | - if (!response.ok) { | |
| 3348 | + headers: getRequestHeaders(), | |
| 3342 | - const text = await response.text(); | |
| 3349 | + body: JSON.stringify({ | |
| 3343 | - throw new Error(text); | |
| 3350 | + model: extension_settings.sd.model, | |
| 3351 | + }), | |
| 3352 | + }); | |
| 3353 | + if (!response.ok) { | |
| 3354 | + const text = await response.text(); | |
| 3355 | + throw new Error(text); | |
| 3356 | + } | |
| 3357 | + const result = await response.json(); | |
| 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; | |
| 3344 | 3368 | } |
| 3345 | - const result = await response.json(); | |
| 3369 | + | |
| 3346 | 3370 | const sizesDatatargetAspect = result.sizestargetWidth / targetHeight; |
| 3371 | + const targetResolution = targetWidth * targetHeight; | |
| 3347 | 3372 | |
| 3348 | 3373 | const closestSize = sizesData.reduce((closest, size) => { |
| 3349 | 3374 | if (!size || typeof size !== 'string') { |
| @@ -3356,16 +3381,14 @@ async function getClosestSize(width, height) { | ||
| 3356 | 3381 | |
| 3357 | 3382 | const sizeWidth = Number(sizeParts[0]); |
| 3358 | 3383 | const sizeHeight = Number(sizeParts[1]); |
| 3359 | - const targetWidth = Number(width); | |
| 3360 | - const targetHeight = Number(height); | |
| 3361 | 3384 | |
| 3362 | 3385 | if (isNaN(sizeWidth) || isNaN(sizeHeight) || isNaN(targetWidth) || isNaN(targetHeight)) { |
| 3363 | 3386 | return closest; |
| 3364 | 3387 | } |
| 3365 | 3388 | |
| 3366 | 3389 | const sizeAreaaspectDiff = Math.abs((sizeWidth */ sizeHeight) - targetAspect) / targetAspect; |
| 3367 | 3390 | const targetArearesolutionDiff = targetWidthMath.abs(sizeWidth * targetHeightsizeHeight - targetResolution) / targetResolution; |
| 3368 | 3391 | const diff = Math.abs(sizeAreaaspectDiff -+ targetArea)resolutionDiff; |
| 3369 | 3392 | |
| 3370 | 3393 | return diff < closest.diff ? { size, diff } : closest; |
| 3371 | 3394 | }, { size: null, diff: Infinity }); |
| @@ -4245,38 +4268,69 @@ async function generateGoogleImage(prompt, negativePrompt, signal) { | ||
| 4245 | 4268 | * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete. |
| 4246 | 4269 | */ |
| 4247 | 4270 | async function generateZaiImage(prompt, signal) { |
| 4248 | - // Round width and height to nearest multiple of 16, and clamp to 512-2048 range | |
| 4271 | + if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) { | |
| 4249 | - let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048); | |
| 4272 | + const videoParams = {}; | |
| 4250 | - let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048); | |
| 4273 | + if (/cogvideox/.test(extension_settings.sd.model)) { | |
| 4251 | - | |
| 4274 | + const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160']; | |
| 4252 | - // Make sure the pixel count does not exceed 2^21px | |
| 4275 | + videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed'; | |
| 4253 | - while ((width * height) > Math.pow(2, 21)) { | |
| 4276 | + videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes); | |
| 4254 | - if (width >= height) { | |
| 4277 | + } | |
| 4255 | - width -= 16; | |
| 4278 | + if (/vidu/.test(extension_settings.sd.model)) { | |
| 4256 | - } else { | |
| 4279 | + videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai'); | |
| 4257 | - height -= 16; | |
| 4258 | 4280 | } |
| 4259 | - } | |
| 4260 | 4281 | |
| 4261 | 4282 | const resultvideoResult = await fetch('/api/sd/zai/generate-video', { |
| 4262 | 4283 | method: 'POST', |
| 4263 | 4284 | headers: getRequestHeaders(), |
| 4264 | 4285 | signal: signal, |
| 4265 | 4286 | body: JSON.stringify({ |
| 4266 | 4287 | prompt: prompt, |
| 4267 | 4288 | model: extension_settings.sd.model, |
| 4268 | - quality: extension_settings.sd.openai_quality, | |
| 4289 | + ...videoParams, | |
| 4269 | - size: `${width}x${height}`, | |
| 4290 | + }), | |
| 4270 | 4291 | }),; |
| 4271 | - }); | |
| 4272 | 4292 | |
| 4273 | 4293 | if (resultvideoResult.ok) { |
| 4274 | 4294 | const data = await resultvideoResult.json(); |
| 4275 | 4295 | return { format: data.format, data: data.imagevideo }; |
| 4276 | 4296 | } |
| 4277 | 4297 | |
| 4278 | 4298 | const text = await resultvideoResult.text(); |
| 4279 | 4299 | throw new Error(text); |
| 4300 | + } else { | |
| 4301 | + // Round width and height to nearest multiple of 16, and clamp to 512-2048 range | |
| 4302 | + let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048); | |
| 4303 | + let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048); | |
| 4304 | + | |
| 4305 | + // Make sure the pixel count does not exceed 2^21px | |
| 4306 | + while ((width * height) > Math.pow(2, 21)) { | |
| 4307 | + if (width >= height) { | |
| 4308 | + width -= 16; | |
| 4309 | + } else { | |
| 4310 | + height -= 16; | |
| 4311 | + } | |
| 4312 | + } | |
| 4313 | + | |
| 4314 | + const result = await fetch('/api/sd/zai/generate', { | |
| 4315 | + method: 'POST', | |
| 4316 | + headers: getRequestHeaders(), | |
| 4317 | + signal: signal, | |
| 4318 | + body: JSON.stringify({ | |
| 4319 | + prompt: prompt, | |
| 4320 | + model: extension_settings.sd.model, | |
| 4321 | + quality: extension_settings.sd.openai_quality, | |
| 4322 | + size: `${width}x${height}`, | |
| 4323 | + }), | |
| 4324 | + }); | |
| 4325 | + | |
| 4326 | + if (result.ok) { | |
| 4327 | + const data = await result.json(); | |
| 4328 | + return { format: data.format, data: data.image }; | |
| 4329 | + } | |
| 4330 | + | |
| 4331 | + const text = await result.text(); | |
| 4332 | + throw new Error(text); | |
| 4333 | + } | |
| 4280 | 4334 | } |
| 4281 | 4335 | |
| 4282 | 4336 | /** |
| @@ -187,7 +187,7 @@ | ||
| 187 | 187 | <option value="high" data-i18n="High">High</option> |
| 188 | 188 | </select> |
| 189 | 189 | </div> |
| 190 | 190 | <div data-sd-model="dall-e-3,cogview-4,cogvideox" class="flex1"> |
| 191 | 191 | <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label> |
| 192 | 192 | <select id="sd_openai_quality"> |
| 193 | 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 | 1909 | router.use('/comfy', comfy); |
| 1809 | 1910 | router.use('/comfyrunpod', comfyRunPod); |
| 1810 | 1911 | router.use('/together', together); |