mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
export const meta = {
|
|
apiVersion: 1,
|
|
key: "sora",
|
|
name: "Sora",
|
|
icon: "Sora.Color",
|
|
description: {
|
|
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
|
|
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
|
|
},
|
|
version: "1.0.0",
|
|
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
|
|
author: { name: "QuantumNous" },
|
|
models: ["sora-2", "sora-2-pro"],
|
|
fetchMode: "per_task",
|
|
usageSchema: {
|
|
seconds: {
|
|
type: "number",
|
|
unit: "second",
|
|
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" },
|
|
},
|
|
size: {
|
|
enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
|
|
description: { en: "Requested output video dimensions.", zh: "请求的输出视频尺寸。" },
|
|
},
|
|
},
|
|
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
|
};
|
|
|
|
function trimmed(value) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function responsesInput(req) {
|
|
const texts = [],
|
|
images = [];
|
|
const input = req.input;
|
|
if (typeof input === "string") texts.push(input);
|
|
else if (Array.isArray(input)) {
|
|
for (const item of input) {
|
|
if (typeof item === "string") {
|
|
texts.push(item);
|
|
continue;
|
|
}
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
|
|
for (const part of content) {
|
|
if (typeof part === "string") {
|
|
texts.push(part);
|
|
continue;
|
|
}
|
|
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
|
|
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
|
|
if (["input_image", "image_url"].includes(part.type)) {
|
|
let image = part.image_url;
|
|
if (image && typeof image === "object") image = image.url;
|
|
if (trimmed(image)) images.push(trimmed(image));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
prompt: texts
|
|
.filter(function (text) {
|
|
return trimmed(text);
|
|
})
|
|
.join("\n"),
|
|
images: images,
|
|
};
|
|
}
|
|
|
|
function responsesVideoText(ctx) {
|
|
const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
|
|
const url = trimmed(artifact && artifact.url);
|
|
if (!url) throw new Error("video artifact is unavailable");
|
|
const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
return '<video controls src="' + escaped + '"></video>';
|
|
}
|
|
|
|
function requestValues(req, model) {
|
|
const values = Object.assign({}, req || {});
|
|
values.model = model;
|
|
return values;
|
|
}
|
|
|
|
export function buildSubmitRequest(ctx) {
|
|
const req = ctx.requestBody || {};
|
|
if (!String(req.prompt || "").trim()) throw new Error("field prompt is required");
|
|
const action = ctx.action === "remix" ? "remix" : ctx.action;
|
|
const headers = { Authorization: "Bearer " + ctx.apiKey };
|
|
if (action === "remix") {
|
|
headers["Content-Type"] = "application/json";
|
|
return { url: ctx.baseUrl + "/v1/videos/" + ctx.originTaskId + "/remix", method: "POST", headers, body: requestValues(req, ctx.upstreamModel), action };
|
|
}
|
|
if ((ctx.files || []).length) {
|
|
const parts = [];
|
|
const values = requestValues(req, ctx.upstreamModel);
|
|
for (const key of Object.keys(values)) {
|
|
if (values[key] !== undefined && values[key] !== null && typeof values[key] !== "object") parts.push({ name: key, value: values[key] });
|
|
}
|
|
if (values.metadata && typeof values.metadata === "object" && !Array.isArray(values.metadata)) {
|
|
parts.push({ name: "metadata", value: JSON.stringify(values.metadata) });
|
|
}
|
|
for (const file of ctx.files) parts.push({ name: file.field, fileRef: file.ref, filename: file.filename });
|
|
return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, bodyType: "multipart", parts };
|
|
}
|
|
headers["Content-Type"] = "application/json";
|
|
return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, body: requestValues(req, ctx.upstreamModel) };
|
|
}
|
|
|
|
export function parseSubmitResponse(ctx, resp) {
|
|
const body = resp.body || {};
|
|
const taskId = body.id || body.task_id;
|
|
if (!taskId) throw new Error("task_id is empty");
|
|
return { taskId, taskData: body };
|
|
}
|
|
|
|
export function extractUsage(ctx) {
|
|
if (ctx.action === "remix") return {};
|
|
const req = ctx.requestBody || {};
|
|
let seconds = Number(req.seconds || req.duration || 4);
|
|
if (!Number.isFinite(seconds) || seconds <= 0) seconds = 4;
|
|
return { seconds: Math.min(seconds, 3600), size: req.size || "720x1280" };
|
|
}
|
|
|
|
export function extractUsageOnComplete(task, taskResult, body) {
|
|
const facts = {};
|
|
const seconds = Number((body || {}).seconds || (body || {}).duration || 0);
|
|
if (Number.isFinite(seconds) && seconds > 0) facts.seconds = Math.min(seconds, 3600);
|
|
const size = trimmed((body || {}).size);
|
|
if (["720x1280", "1280x720", "1792x1024", "1024x1792"].includes(size)) facts.size = size;
|
|
return facts;
|
|
}
|
|
|
|
export function buildQueryRequest(ctx) {
|
|
return { url: ctx.baseUrl + "/v1/videos/" + ctx.taskId, method: "GET", headers: { Authorization: "Bearer " + ctx.apiKey } };
|
|
}
|
|
|
|
export function parseTaskResult(ctx, body) {
|
|
const statuses = {
|
|
queued: "QUEUED",
|
|
pending: "QUEUED",
|
|
processing: "IN_PROGRESS",
|
|
in_progress: "IN_PROGRESS",
|
|
completed: "SUCCESS",
|
|
failed: "FAILURE",
|
|
cancelled: "FAILURE",
|
|
};
|
|
const result = { status: statuses[body.status] || "UNKNOWN" };
|
|
if (body.progress > 0 && body.progress < 100) result.progress = body.progress + "%";
|
|
if (result.status === "FAILURE") result.reason = body.error && body.error.message ? body.error.message : "task failed";
|
|
return result;
|
|
}
|
|
|
|
export function listArtifacts(task) {
|
|
return task.status === "SUCCESS" ? [{ key: "video", type: "video" }] : [];
|
|
}
|
|
|
|
export function buildContentRequest(ctx) {
|
|
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
|
return {
|
|
url: ctx.baseUrl + "/v1/videos/" + encodeURIComponent(ctx.upstreamTaskId) + "/content",
|
|
method: ctx.clientRequest.method,
|
|
headers: { Authorization: "Bearer " + ctx.apiKey },
|
|
};
|
|
}
|
|
|
|
export const protocols = {
|
|
openai_responses: {
|
|
decodeRequest: function (ctx) {
|
|
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
|
const req = ctx.body.value;
|
|
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
|
|
const model = trimmed(req.model);
|
|
if (!model) throw new Error("model is required");
|
|
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
|
|
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
|
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
|
|
throw new Error("metadata must be an object");
|
|
const input = responsesInput(req);
|
|
const prompt = input.prompt || trimmed(req.prompt);
|
|
if (!prompt) throw new Error("input is required");
|
|
const images = [];
|
|
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
|
|
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
|
|
}
|
|
const requestBody = { model: model, prompt: prompt };
|
|
if (images.length) requestBody.input_reference = images[0];
|
|
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
|
|
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
|
|
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
|
|
if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
|
|
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
|
},
|
|
renderEvents: function (ctx, task, previousState) {
|
|
const status = String(task.status || "UNKNOWN").toUpperCase();
|
|
const value = Number(String(task.progress || "").replace("%", ""));
|
|
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
|
const state = { status: status, progress: progress };
|
|
if (status === "SUCCESS") {
|
|
const text = responsesVideoText(ctx);
|
|
const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
|
|
return { events: events, state: state, done: true };
|
|
}
|
|
if (status === "FAILURE")
|
|
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
|
|
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
|
|
const event = { type: "progress", message: status.toLowerCase() };
|
|
if (progress !== null) event.progress = progress;
|
|
return { events: [event], state: state, done: false };
|
|
},
|
|
renderFinal: function (ctx, _task) {
|
|
return {
|
|
output: [
|
|
{
|
|
type: "message",
|
|
status: "completed",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
|
|
},
|
|
],
|
|
metadata: { vendor: "sora" },
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
const legacyRenderers = {
|
|
openai_video: function (task) {
|
|
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
|
|
const output = {
|
|
id: task.task_id,
|
|
object: "video",
|
|
model: (task.properties || {}).origin_model_name || "",
|
|
status: statuses[task.status] || "unknown",
|
|
progress: Number(String(task.progress || "0").replace("%", "")),
|
|
created_at: Number(task.created_at || 0),
|
|
};
|
|
const completedAt = Number(task.finished_at || task.updated_at || 0);
|
|
if (completedAt > 0) output.completed_at = completedAt;
|
|
if (task.status === "FAILURE") {
|
|
output.error = { code: "video_generation_failed", message: "The video generation task failed." };
|
|
}
|
|
return output;
|
|
},
|
|
};
|
|
|
|
protocols.openai_video = {
|
|
decodeRequest: function (ctx) {
|
|
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
|
|
if (ctx.body.kind === "json") {
|
|
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
|
const req = ctx.body.value;
|
|
const seconds = req.seconds === undefined ? req.duration : req.seconds;
|
|
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
|
|
throw new Error("seconds must be between 1 and 3600");
|
|
return {
|
|
kind: "submit",
|
|
model: ctx.model,
|
|
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
|
|
requestBody: Object.assign({}, req, { model: ctx.model }),
|
|
};
|
|
}
|
|
const first = function (name) {
|
|
const values = (ctx.body.fields || {})[name] || [];
|
|
if (values.length > 1) throw new Error(name + " must be provided once");
|
|
return values[0];
|
|
};
|
|
const req = {};
|
|
const fields = ctx.body.fields || {};
|
|
for (const name of Object.keys(fields)) {
|
|
req[name] = first(name);
|
|
}
|
|
let hasInputReferenceFile = false;
|
|
for (const file of ctx.body.files || []) {
|
|
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
|
|
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
|
|
hasInputReferenceFile = true;
|
|
}
|
|
if (req.metadata !== undefined) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(req.metadata);
|
|
} catch (e) {
|
|
throw new Error("metadata must be a JSON object string");
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
|
|
req.metadata = parsed;
|
|
}
|
|
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
|
|
else if (req.duration !== undefined) req.seconds = Number(req.duration);
|
|
const seconds = req.seconds === undefined ? req.duration : req.seconds;
|
|
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
|
|
throw new Error("seconds must be between 1 and 3600");
|
|
return {
|
|
kind: "submit",
|
|
model: ctx.model,
|
|
action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
|
|
requestBody: Object.assign({}, req, { model: ctx.model }),
|
|
};
|
|
},
|
|
render: function (ctx, task) {
|
|
return legacyRenderers.openai_video(task);
|
|
},
|
|
};
|