OpenAI: Add Sora 2 API (#4748) * OpenAI: Add Sora 2 API * Add duration control * Support client generation abort * Reduce poll log amount * Simplify selector * Simplify model-specific control handling
Signed| @@ -311,6 +311,7 @@ const defaultSettings = { | ||
| 311 | 311 | // OpenAI settings |
| 312 | 312 | openai_style: 'vivid', |
| 313 | 313 | openai_quality: 'standard', |
| 314 | + openai_duration: '8', | |
| 314 | 315 | |
| 315 | 316 | style: 'Default', |
| 316 | 317 | styles: defaultStyles, |
| @@ -510,6 +511,7 @@ async function loadSettings() { | ||
| 510 | 511 | $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode); |
| 511 | 512 | $('#sd_openai_style').val(extension_settings.sd.openai_style); |
| 512 | 513 | $('#sd_openai_quality').val(extension_settings.sd.openai_quality); |
| 514 | + $('#sd_openai_duration').val(extension_settings.sd.openai_duration); | |
| 513 | 515 | $('#sd_comfy_url').val(extension_settings.sd.comfy_url); |
| 514 | 516 | $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt); |
| 515 | 517 | $('#sd_snap').prop('checked', extension_settings.sd.snap); |
| @@ -1025,6 +1027,11 @@ async function onOpenAiQualitySelect() { | ||
| 1025 | 1027 | saveSettingsDebounced(); |
| 1026 | 1028 | } |
| 1027 | 1029 | |
| 1030 | +async function onOpenAiDurationSelect() { | |
| 1031 | + extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val()); | |
| 1032 | + saveSettingsDebounced(); | |
| 1033 | +} | |
| 1034 | + | |
| 1028 | 1035 | async function onViewAnlasClick() { |
| 1029 | 1036 | const result = await loadNovelSubscriptionData(); |
| 1030 | 1037 | |
| @@ -1295,6 +1302,8 @@ async function onModelChange() { | ||
| 1295 | 1302 | ensureElectronHubQualitySelect(models); |
| 1296 | 1303 | } |
| 1297 | 1304 | |
| 1305 | + switchModelSpecificControls(extension_settings.sd.model); | |
| 1306 | + | |
| 1298 | 1307 | const cloudSources = [ |
| 1299 | 1308 | sources.horde, |
| 1300 | 1309 | sources.novel, |
| @@ -1745,6 +1754,8 @@ async function loadModels() { | ||
| 1745 | 1754 | ensureElectronHubQualitySelect(models); |
| 1746 | 1755 | } |
| 1747 | 1756 | |
| 1757 | + switchModelSpecificControls(extension_settings.sd.model); | |
| 1758 | + | |
| 1748 | 1759 | for (const model of models) { |
| 1749 | 1760 | const option = document.createElement('option'); |
| 1750 | 1761 | option.innerText = model.text; |
| @@ -1761,6 +1772,24 @@ async function loadModels() { | ||
| 1761 | 1772 | } |
| 1762 | 1773 | |
| 1763 | 1774 | /** |
| 1775 | + * Show or hide model-specific controls based on the selected model. | |
| 1776 | + * @param {string} modelId Model ID | |
| 1777 | + */ | |
| 1778 | +function switchModelSpecificControls(modelId) { | |
| 1779 | + const modelControls = $('.sd_settings [data-sd-model]'); | |
| 1780 | + modelControls.hide(); | |
| 1781 | + | |
| 1782 | + if (!modelId) { | |
| 1783 | + return; | |
| 1784 | + } | |
| 1785 | + | |
| 1786 | + modelControls.each(function () { | |
| 1787 | + const models = String($(this).attr('data-sd-model') || '').split(',').map(m => m.trim()); | |
| 1788 | + $(this).toggle(models.includes(modelId)); | |
| 1789 | + }); | |
| 1790 | +} | |
| 1791 | + | |
| 1792 | +/** | |
| 1764 | 1793 | * Ensure the Electron Hub quality select is populated based on the selected model. |
| 1765 | 1794 | * @param {any[]} models Array of models |
| 1766 | 1795 | */ |
| @@ -2054,6 +2083,8 @@ async function loadOpenAiModels() { | ||
| 2054 | 2083 | { value: 'gpt-image-1', text: 'gpt-image-1' }, |
| 2055 | 2084 | { value: 'dall-e-3', text: 'dall-e-3' }, |
| 2056 | 2085 | { value: 'dall-e-2', text: 'dall-e-2' }, |
| 2086 | + { value: 'sora-2', text: 'sora-2' }, | |
| 2087 | + { value: 'sora-2-pro', text: 'sora-2-pro' }, | |
| 2057 | 2088 | ]; |
| 2058 | 2089 | } |
| 2059 | 2090 | |
| @@ -3512,6 +3543,7 @@ async function generateOpenAiImage(prompt, signal) { | ||
| 3512 | 3543 | const isDalle2 = extension_settings.sd.model === 'dall-e-2'; |
| 3513 | 3544 | const isDalle3 = extension_settings.sd.model === 'dall-e-3'; |
| 3514 | 3545 | const isGptImg = extension_settings.sd.model === 'gpt-image-1'; |
| 3546 | + const isSora2 = /sora-2/.test(extension_settings.sd.model); | |
| 3515 | 3547 | |
| 3516 | 3548 | if (isDalle2 && prompt.length > dalle2PromptLimit) { |
| 3517 | 3549 | prompt = prompt.substring(0, dalle2PromptLimit); |
| @@ -3550,6 +3582,30 @@ async function generateOpenAiImage(prompt, signal) { | ||
| 3550 | 3582 | height = 512; |
| 3551 | 3583 | } |
| 3552 | 3584 | |
| 3585 | + if (isSora2) { | |
| 3586 | + width = aspectRatio >= 1 ? 1280 : 720; | |
| 3587 | + height = aspectRatio >= 1 ? 720 : 1280; | |
| 3588 | + | |
| 3589 | + const videoResult = await fetch('/api/openai/generate-video', { | |
| 3590 | + method: 'POST', | |
| 3591 | + headers: getRequestHeaders(), | |
| 3592 | + signal: signal, | |
| 3593 | + body: JSON.stringify({ | |
| 3594 | + prompt: prompt, | |
| 3595 | + model: extension_settings.sd.model, | |
| 3596 | + size: `${width}x${height}`, | |
| 3597 | + seconds: extension_settings.sd.openai_duration, | |
| 3598 | + }), | |
| 3599 | + }); | |
| 3600 | + | |
| 3601 | + if (!videoResult.ok) { | |
| 3602 | + throw new Error(await videoResult.text()); | |
| 3603 | + } | |
| 3604 | + | |
| 3605 | + const { format, data } = await videoResult.json(); | |
| 3606 | + return { format, data }; | |
| 3607 | + } | |
| 3608 | + | |
| 3553 | 3609 | const result = await fetch('/api/openai/generate-image', { |
| 3554 | 3610 | method: 'POST', |
| 3555 | 3611 | headers: getRequestHeaders(), |
| @@ -4859,6 +4915,7 @@ jQuery(async () => { | ||
| 4859 | 4915 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); |
| 4860 | 4916 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); |
| 4861 | 4917 | $('#sd_openai_quality').on('change', onOpenAiQualitySelect); |
| 4918 | + $('#sd_openai_duration').on('input', onOpenAiDurationSelect); | |
| 4862 | 4919 | $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput); |
| 4863 | 4920 | $('#sd_snap').on('input', onSnapInput); |
| 4864 | 4921 | $('#sd_clip_skip').on('input', onClipSkipInput); |
| @@ -161,8 +161,7 @@ | ||
| 161 | 161 | </div> |
| 162 | 162 | </div> |
| 163 | 163 | <div data-sd-source="openai,aimlapi"> |
| 164 | - <small data-i18n="These settings only apply to DALL-E 3">These settings only apply to DALL-E 3</small> | |
| 164 | + <div data-sd-model="dall-e-3" class="flex-container"> | |
| 165 | - <div class="flex-container"> | |
| 166 | 165 | <div class="flex1"> |
| 167 | 166 | <label for="sd_openai_style" data-i18n="Image Style">Image Style</label> |
| 168 | 167 | <select id="sd_openai_style"> |
| @@ -178,6 +177,16 @@ | ||
| 178 | 177 | </select> |
| 179 | 178 | </div> |
| 180 | 179 | </div> |
| 180 | + <div data-sd-model="sora-2,sora-2-pro" class="flex-container"> | |
| 181 | + <div class="flex1"> | |
| 182 | + <label for="sd_openai_duration" data-i18n="Duration">Duration</label> | |
| 183 | + <select id="sd_openai_duration"> | |
| 184 | + <option value="4" data-i18n="Short (4 seconds)">Short (4 seconds)</option> | |
| 185 | + <option value="8" data-i18n="Medium (8 seconds)">Medium (8 seconds)</option> | |
| 186 | + <option value="12" data-i18n="Long (16 seconds)">Long (12 seconds)</option> | |
| 187 | + </select> | |
| 188 | + </div> | |
| 189 | + </div> | |
| 181 | 190 | </div> |
| 182 | 191 | <div data-sd-source="comfy"> |
| 183 | 192 | <label for="sd_comfy_url">ComfyUI URL</label> |
| @@ -5,7 +5,7 @@ import fetch from 'node-fetch'; | ||
| 5 | 5 | import FormData from 'form-data'; |
| 6 | 6 | import express from 'express'; |
| 7 | 7 | |
| 8 | 8 | import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js'; |
| 9 | 9 | import { setAdditionalHeaders } from '../additional-headers.js'; |
| 10 | 10 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 11 | 11 | import { AIMLAPI_HEADERS, OPENROUTER_HEADERS } from '../constants.js'; |
| @@ -465,6 +465,107 @@ router.post('/generate-image', async (request, response) => { | ||
| 465 | 465 | } |
| 466 | 466 | }); |
| 467 | 467 | |
| 468 | +router.post('/generate-video', async (request, response) => { | |
| 469 | + try { | |
| 470 | + const controller = new AbortController(); | |
| 471 | + request.socket.removeAllListeners('close'); | |
| 472 | + request.socket.on('close', function () { | |
| 473 | + controller.abort(); | |
| 474 | + }); | |
| 475 | + | |
| 476 | + const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI); | |
| 477 | + | |
| 478 | + if (!key) { | |
| 479 | + console.warn('No OpenAI key found'); | |
| 480 | + return response.sendStatus(400); | |
| 481 | + } | |
| 482 | + | |
| 483 | + console.debug('OpenAI video generation request', request.body); | |
| 484 | + | |
| 485 | + const videoJobResponse = await fetch('https://api.openai.com/v1/videos', { | |
| 486 | + method: 'POST', | |
| 487 | + headers: { | |
| 488 | + 'Content-Type': 'application/json', | |
| 489 | + 'Authorization': `Bearer ${key}`, | |
| 490 | + }, | |
| 491 | + body: JSON.stringify({ | |
| 492 | + prompt: request.body.prompt, | |
| 493 | + model: request.body.model || 'sora-2', | |
| 494 | + size: request.body.size || '720x1280', | |
| 495 | + seconds: request.body.seconds || '8', | |
| 496 | + }), | |
| 497 | + }); | |
| 498 | + | |
| 499 | + if (!videoJobResponse.ok) { | |
| 500 | + const text = await videoJobResponse.text(); | |
| 501 | + console.warn('OpenAI video generation request failed', videoJobResponse.statusText, text); | |
| 502 | + return response.status(500).send(text); | |
| 503 | + } | |
| 504 | + | |
| 505 | + /** @type {any} */ | |
| 506 | + const videoJob = await videoJobResponse.json(); | |
| 507 | + | |
| 508 | + if (!videoJob || !videoJob.id) { | |
| 509 | + console.warn('OpenAI video generation returned no job ID', videoJob); | |
| 510 | + return response.status(500).send('No video job ID returned'); | |
| 511 | + } | |
| 512 | + | |
| 513 | + // Poll for video generation completion | |
| 514 | + for (let attempt = 0; attempt < 30; attempt++) { | |
| 515 | + if (controller.signal.aborted) { | |
| 516 | + console.info('OpenAI video generation aborted by client'); | |
| 517 | + return response.status(500).send('Video generation aborted by client'); | |
| 518 | + } | |
| 519 | + | |
| 520 | + await delay(5000 + attempt * 1000); | |
| 521 | + console.debug(`Polling OpenAI video job ${videoJob.id}, attempt ${attempt + 1}`); | |
| 522 | + | |
| 523 | + const pollResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}`, { | |
| 524 | + method: 'GET', | |
| 525 | + headers: { | |
| 526 | + 'Authorization': `Bearer ${key}`, | |
| 527 | + }, | |
| 528 | + }); | |
| 529 | + | |
| 530 | + if (!pollResponse.ok) { | |
| 531 | + const text = await pollResponse.text(); | |
| 532 | + console.warn('OpenAI video job polling failed', pollResponse.statusText, text); | |
| 533 | + return response.status(500).send(text); | |
| 534 | + } | |
| 535 | + | |
| 536 | + /** @type {any} */ | |
| 537 | + const pollResult = await pollResponse.json(); | |
| 538 | + console.debug(`OpenAI video job status: ${pollResult.status}, progress: ${pollResult.progress}`); | |
| 539 | + | |
| 540 | + if (pollResult.status === 'failed') { | |
| 541 | + console.warn('OpenAI video generation failed', pollResult); | |
| 542 | + return response.status(500).send('Video generation failed'); | |
| 543 | + } | |
| 544 | + | |
| 545 | + if (pollResult.status === 'completed') { | |
| 546 | + const contentResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}/content`, { | |
| 547 | + method: 'GET', | |
| 548 | + headers: { | |
| 549 | + 'Authorization': `Bearer ${key}`, | |
| 550 | + }, | |
| 551 | + }); | |
| 552 | + | |
| 553 | + if (!contentResponse.ok) { | |
| 554 | + const text = await contentResponse.text(); | |
| 555 | + console.warn('OpenAI video content fetch failed', contentResponse.statusText, text); | |
| 556 | + return response.status(500).send(text); | |
| 557 | + } | |
| 558 | + | |
| 559 | + const contentBuffer = await contentResponse.arrayBuffer(); | |
| 560 | + return response.send({ format: 'mp4', data: Buffer.from(contentBuffer).toString('base64') }); | |
| 561 | + } | |
| 562 | + } | |
| 563 | + } catch (error) { | |
| 564 | + console.error('OpenAI video generation failed', error); | |
| 565 | + response.status(500).send('Internal server error'); | |
| 566 | + } | |
| 567 | +}); | |
| 568 | + | |
| 468 | 569 | const custom = express.Router(); |
| 469 | 570 | |
| 470 | 571 | custom.post('/generate-voice', async (request, response) => { |