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 | // OpenAI settings | 311 | // OpenAI settings |
| 312 | openai_style: 'vivid', | 312 | openai_style: 'vivid', |
| 313 | openai_quality: 'standard', | 313 | openai_quality: 'standard', |
| 314 | openai_duration: '8', | ||
| 314 | 315 | ||
| 315 | style: 'Default', | 316 | style: 'Default', |
| 316 | styles: defaultStyles, | 317 | styles: defaultStyles, |
| @@ -510,6 +511,7 @@ async function loadSettings() { | |||
| 510 | $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode); | 511 | $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode); |
| 511 | $('#sd_openai_style').val(extension_settings.sd.openai_style); | 512 | $('#sd_openai_style').val(extension_settings.sd.openai_style); |
| 512 | $('#sd_openai_quality').val(extension_settings.sd.openai_quality); | 513 | $('#sd_openai_quality').val(extension_settings.sd.openai_quality); |
| 514 | $('#sd_openai_duration').val(extension_settings.sd.openai_duration); | ||
| 513 | $('#sd_comfy_url').val(extension_settings.sd.comfy_url); | 515 | $('#sd_comfy_url').val(extension_settings.sd.comfy_url); |
| 514 | $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt); | 516 | $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt); |
| 515 | $('#sd_snap').prop('checked', extension_settings.sd.snap); | 517 | $('#sd_snap').prop('checked', extension_settings.sd.snap); |
| @@ -1025,6 +1027,11 @@ async function onOpenAiQualitySelect() { | |||
| 1025 | saveSettingsDebounced(); | 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 | async function onViewAnlasClick() { | 1035 | async function onViewAnlasClick() { |
| 1029 | const result = await loadNovelSubscriptionData(); | 1036 | const result = await loadNovelSubscriptionData(); |
| 1030 | 1037 | ||
| @@ -1295,6 +1302,8 @@ async function onModelChange() { | |||
| 1295 | ensureElectronHubQualitySelect(models); | 1302 | ensureElectronHubQualitySelect(models); |
| 1296 | } | 1303 | } |
| 1297 | 1304 | ||
| 1305 | switchModelSpecificControls(extension_settings.sd.model); | ||
| 1306 | |||
| 1298 | const cloudSources = [ | 1307 | const cloudSources = [ |
| 1299 | sources.horde, | 1308 | sources.horde, |
| 1300 | sources.novel, | 1309 | sources.novel, |
| @@ -1745,6 +1754,8 @@ async function loadModels() { | |||
| 1745 | ensureElectronHubQualitySelect(models); | 1754 | ensureElectronHubQualitySelect(models); |
| 1746 | } | 1755 | } |
| 1747 | 1756 | ||
| 1757 | switchModelSpecificControls(extension_settings.sd.model); | ||
| 1758 | |||
| 1748 | for (const model of models) { | 1759 | for (const model of models) { |
| 1749 | const option = document.createElement('option'); | 1760 | const option = document.createElement('option'); |
| 1750 | option.innerText = model.text; | 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 | * Ensure the Electron Hub quality select is populated based on the selected model. | 1793 | * Ensure the Electron Hub quality select is populated based on the selected model. |
| 1765 | * @param {any[]} models Array of models | 1794 | * @param {any[]} models Array of models |
| 1766 | */ | 1795 | */ |
| @@ -2054,6 +2083,8 @@ async function loadOpenAiModels() { | |||
| 2054 | { value: 'gpt-image-1', text: 'gpt-image-1' }, | 2083 | { value: 'gpt-image-1', text: 'gpt-image-1' }, |
| 2055 | { value: 'dall-e-3', text: 'dall-e-3' }, | 2084 | { value: 'dall-e-3', text: 'dall-e-3' }, |
| 2056 | { value: 'dall-e-2', text: 'dall-e-2' }, | 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 | const isDalle2 = extension_settings.sd.model === 'dall-e-2'; | 3543 | const isDalle2 = extension_settings.sd.model === 'dall-e-2'; |
| 3513 | const isDalle3 = extension_settings.sd.model === 'dall-e-3'; | 3544 | const isDalle3 = extension_settings.sd.model === 'dall-e-3'; |
| 3514 | const isGptImg = extension_settings.sd.model === 'gpt-image-1'; | 3545 | const isGptImg = extension_settings.sd.model === 'gpt-image-1'; |
| 3546 | const isSora2 = /sora-2/.test(extension_settings.sd.model); | ||
| 3515 | 3547 | ||
| 3516 | if (isDalle2 && prompt.length > dalle2PromptLimit) { | 3548 | if (isDalle2 && prompt.length > dalle2PromptLimit) { |
| 3517 | prompt = prompt.substring(0, dalle2PromptLimit); | 3549 | prompt = prompt.substring(0, dalle2PromptLimit); |
| @@ -3550,6 +3582,30 @@ async function generateOpenAiImage(prompt, signal) { | |||
| 3550 | height = 512; | 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 | const result = await fetch('/api/openai/generate-image', { | 3609 | const result = await fetch('/api/openai/generate-image', { |
| 3554 | method: 'POST', | 3610 | method: 'POST', |
| 3555 | headers: getRequestHeaders(), | 3611 | headers: getRequestHeaders(), |
| @@ -4859,6 +4915,7 @@ jQuery(async () => { | |||
| 4859 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); | 4915 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); |
| 4860 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); | 4916 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); |
| 4861 | $('#sd_openai_quality').on('change', onOpenAiQualitySelect); | 4917 | $('#sd_openai_quality').on('change', onOpenAiQualitySelect); |
| 4918 | $('#sd_openai_duration').on('input', onOpenAiDurationSelect); | ||
| 4862 | $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput); | 4919 | $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput); |
| 4863 | $('#sd_snap').on('input', onSnapInput); | 4920 | $('#sd_snap').on('input', onSnapInput); |
| 4864 | $('#sd_clip_skip').on('input', onClipSkipInput); | 4921 | $('#sd_clip_skip').on('input', onClipSkipInput); |
| @@ -161,8 +161,7 @@ | |||
| 161 | </div> | 161 | </div> |
| 162 | </div> | 162 | </div> |
| 163 | <div data-sd-source="openai,aimlapi"> | 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 | <div class="flex1"> | 165 | <div class="flex1"> |
| 167 | <label for="sd_openai_style" data-i18n="Image Style">Image Style</label> | 166 | <label for="sd_openai_style" data-i18n="Image Style">Image Style</label> |
| 168 | <select id="sd_openai_style"> | 167 | <select id="sd_openai_style"> |
| @@ -178,6 +177,16 @@ | |||
| 178 | </select> | 177 | </select> |
| 179 | </div> | 178 | </div> |
| 180 | </div> | 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 | </div> | 190 | </div> |
| 182 | <div data-sd-source="comfy"> | 191 | <div data-sd-source="comfy"> |
| 183 | <label for="sd_comfy_url">ComfyUI URL</label> | 192 | <label for="sd_comfy_url">ComfyUI URL</label> |
| @@ -5,7 +5,7 @@ import fetch from 'node-fetch'; | |||
| 5 | import FormData from 'form-data'; | 5 | import FormData from 'form-data'; |
| 6 | import express from 'express'; | 6 | import express from 'express'; |
| 7 | 7 | ||
| 8 | import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1 } from '../util.js'; | 8 | import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js'; |
| 9 | import { setAdditionalHeaders } from '../additional-headers.js'; | 9 | import { setAdditionalHeaders } from '../additional-headers.js'; |
| 10 | import { readSecret, SECRET_KEYS } from './secrets.js'; | 10 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 11 | import { AIMLAPI_HEADERS, OPENROUTER_HEADERS } from '../constants.js'; | 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 | const custom = express.Router(); | 569 | const custom = express.Router(); |
| 469 | 570 | ||
| 470 | custom.post('/generate-voice', async (request, response) => { | 571 | custom.post('/generate-voice', async (request, response) => { |