Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
316 lines
13 KiB
JavaScript
316 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.3",
|
|
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: {
|
|
// Requested video duration in seconds.
|
|
seconds: {
|
|
type: "number",
|
|
unit: "second",
|
|
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
|
},
|
|
// Requested output video dimensions.
|
|
size: {
|
|
enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
|
|
enumLabels: {
|
|
"720x1280": { en: "720x1280", zh: "720x1280" },
|
|
"1280x720": { en: "1280x720", zh: "1280x720" },
|
|
"1792x1024": { en: "1792x1024", zh: "1792x1024" },
|
|
"1024x1792": { en: "1024x1792", zh: "1024x1792" },
|
|
},
|
|
description: { en: "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 mapped = statuses[body.status];
|
|
const result = { status: mapped || "UNKNOWN" };
|
|
if (!mapped) result.reason = "unrecognized status: " + String(body.status || "");
|
|
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) {
|
|
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return task.data;
|
|
return legacyRenderers.openai_video(task);
|
|
},
|
|
};
|