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 -3Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+57 -0
@@ -311,6 +311,7 @@ const defaultSettings = {
311311 // OpenAI settings
312312 openai_style: 'vivid',
313313 openai_quality: 'standard',
314+ openai_duration: '8',
314315
315316 style: 'Default',
316317 styles: defaultStyles,
@@ -510,6 +511,7 @@ async function loadSettings() {
510511 $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode);
511512 $('#sd_openai_style').val(extension_settings.sd.openai_style);
512513 $('#sd_openai_quality').val(extension_settings.sd.openai_quality);
514+ $('#sd_openai_duration').val(extension_settings.sd.openai_duration);
513515 $('#sd_comfy_url').val(extension_settings.sd.comfy_url);
514516 $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt);
515517 $('#sd_snap').prop('checked', extension_settings.sd.snap);
@@ -1025,6 +1027,11 @@ async function onOpenAiQualitySelect() {
10251027 saveSettingsDebounced();
10261028}
10271029
1030+async function onOpenAiDurationSelect() {
1031+ extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val());
1032+ saveSettingsDebounced();
1033+}
1034+
10281035async function onViewAnlasClick() {
10291036 const result = await loadNovelSubscriptionData();
10301037
@@ -1295,6 +1302,8 @@ async function onModelChange() {
12951302 ensureElectronHubQualitySelect(models);
12961303 }
12971304
1305+ switchModelSpecificControls(extension_settings.sd.model);
1306+
12981307 const cloudSources = [
12991308 sources.horde,
13001309 sources.novel,
@@ -1745,6 +1754,8 @@ async function loadModels() {
17451754 ensureElectronHubQualitySelect(models);
17461755 }
17471756
1757+ switchModelSpecificControls(extension_settings.sd.model);
1758+
17481759 for (const model of models) {
17491760 const option = document.createElement('option');
17501761 option.innerText = model.text;
@@ -1761,6 +1772,24 @@ async function loadModels() {
17611772}
17621773
17631774/**
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+/**
17641793 * Ensure the Electron Hub quality select is populated based on the selected model.
17651794 * @param {any[]} models Array of models
17661795 */
@@ -2054,6 +2083,8 @@ async function loadOpenAiModels() {
20542083 { value: 'gpt-image-1', text: 'gpt-image-1' },
20552084 { value: 'dall-e-3', text: 'dall-e-3' },
20562085 { value: 'dall-e-2', text: 'dall-e-2' },
2086+ { value: 'sora-2', text: 'sora-2' },
2087+ { value: 'sora-2-pro', text: 'sora-2-pro' },
20572088 ];
20582089}
20592090
@@ -3512,6 +3543,7 @@ async function generateOpenAiImage(prompt, signal) {
35123543 const isDalle2 = extension_settings.sd.model === 'dall-e-2';
35133544 const isDalle3 = extension_settings.sd.model === 'dall-e-3';
35143545 const isGptImg = extension_settings.sd.model === 'gpt-image-1';
3546+ const isSora2 = /sora-2/.test(extension_settings.sd.model);
35153547
35163548 if (isDalle2 && prompt.length > dalle2PromptLimit) {
35173549 prompt = prompt.substring(0, dalle2PromptLimit);
@@ -3550,6 +3582,30 @@ async function generateOpenAiImage(prompt, signal) {
35503582 height = 512;
35513583 }
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+
35533609 const result = await fetch('/api/openai/generate-image', {
35543610 method: 'POST',
35553611 headers: getRequestHeaders(),
@@ -4859,6 +4915,7 @@ jQuery(async () => {
48594915 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
48604916 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
48614917 $('#sd_openai_quality').on('change', onOpenAiQualitySelect);
4918+ $('#sd_openai_duration').on('input', onOpenAiDurationSelect);
48624919 $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput);
48634920 $('#sd_snap').on('input', onSnapInput);
48644921 $('#sd_clip_skip').on('input', onClipSkipInput);
public/scripts/extensions/stable-diffusion/settings.html+11 -2
@@ -161,8 +161,7 @@
161161 </div>
162162 </div>
163163 <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">
166165 <div class="flex1">
167166 <label for="sd_openai_style" data-i18n="Image Style">Image Style</label>
168167 <select id="sd_openai_style">
@@ -178,6 +177,16 @@
178177 </select>
179178 </div>
180179 </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>
181190 </div>
182191 <div data-sd-source="comfy">
183192 <label for="sd_comfy_url">ComfyUI URL</label>
src/endpoints/openai.js+102 -1
@@ -5,7 +5,7 @@ import fetch from 'node-fetch';
55import FormData from 'form-data';
66import express from 'express';
77
88import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js';
99import { setAdditionalHeaders } from '../additional-headers.js';
1010import { readSecret, SECRET_KEYS } from './secrets.js';
1111import { AIMLAPI_HEADERS, OPENROUTER_HEADERS } from '../constants.js';
@@ -465,6 +465,107 @@ router.post('/generate-image', async (request, response) => {
465465 }
466466});
467467
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+
468569const custom = express.Router();
469570
470571custom.post('/generate-voice', async (request, response) => {