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

b99ccc3f0ca03e4413310d8cc72fe9dc4cb638e6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +170 -3Showing whitespace changes
public/scripts/extensions/stable-diffusion/index.js+57 -0
@@ -311,6 +311,7 @@ const defaultSettings = {
311 // OpenAI settings311 // OpenAI settings
312 openai_style: 'vivid',312 openai_style: 'vivid',
313 openai_quality: 'standard',313 openai_quality: 'standard',
314 openai_duration: '8',
314315
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}
10271029
1030async function onOpenAiDurationSelect() {
1031 extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val());
1032 saveSettingsDebounced();
1033}
1034
1028async function onViewAnlasClick() {1035async function onViewAnlasClick() {
1029 const result = await loadNovelSubscriptionData();1036 const result = await loadNovelSubscriptionData();
10301037
@@ -1295,6 +1302,8 @@ async function onModelChange() {
1295 ensureElectronHubQualitySelect(models);1302 ensureElectronHubQualitySelect(models);
1296 }1303 }
12971304
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 }
17471756
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}
17621773
1763/**1774/**
1775 * Show or hide model-specific controls based on the selected model.
1776 * @param {string} modelId Model ID
1777 */
1778function 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 models1794 * @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}
20592090
@@ -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);
35153547
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 }
35523584
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);
public/scripts/extensions/stable-diffusion/settings.html+11 -2
@@ -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>
src/endpoints/openai.js+102 -1
@@ -5,7 +5,7 @@ import fetch from 'node-fetch';
5import FormData from 'form-data';5import FormData from 'form-data';
6import express from 'express';6import express from 'express';
77
8import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1 } from '../util.js';8import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js';
9import { setAdditionalHeaders } from '../additional-headers.js';9import { setAdditionalHeaders } from '../additional-headers.js';
10import { readSecret, SECRET_KEYS } from './secrets.js';10import { readSecret, SECRET_KEYS } from './secrets.js';
11import { AIMLAPI_HEADERS, OPENROUTER_HEADERS } from '../constants.js';11import { AIMLAPI_HEADERS, OPENROUTER_HEADERS } from '../constants.js';
@@ -465,6 +465,107 @@ router.post('/generate-image', async (request, response) => {
465 }465 }
466});466});
467467
468router.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
468const custom = express.Router();569const custom = express.Router();
469570
470custom.post('/generate-voice', async (request, response) => {571custom.post('/generate-voice', async (request, response) => {