mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
Channel model_mapping keys exposed in a channel's model list now act as first-class aliases for task-plugin models across the whole line: - Derived alias view (model/task_model_alias.go): built from enabled channels' model_mapping, chain-following with cycle detection, declared names always win, cross-plugin conflicts dropped. Rebuilt on channel cache refresh, registry generation change, and a 60s TTL. - Request path: PinTaskPluginEndpoint resolves declared-name case folds and mapping aliases before endpoint lookup (never rewriting the body until the endpoint is claimed), pins with MappedModel, and the decode contract accepts alias echoes without loosening model ownership for normal pins. Legacy /v1/tasks submit folds case variants the same way. Fixes aliases on POST /v1/responses silently falling through to the main relay against task channels. - Mapping order: ModelMappedHelper now runs before the plugin submit hook builds and caches the upstream body, so channel model_mapping actually reaches the upstream request. Plugins receive the mapped name as ctx.upstreamModel in both decode and submit contexts. - Billing: identity stays the origin name; when the alias has no tiered expression, the selected channel's mapping tail expression applies. Pricing page and billing-expr smoke tests resolve aliases to the owning plugin's usage schema. - Case folding: ASCII-only fold with exact-match priority; same-plugin and cross-plugin fold collisions rejected at registration. - Plugins: model-keyed rate tables, req_key derivation, and combo validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
502 lines
22 KiB
JavaScript
502 lines
22 KiB
JavaScript
export const meta = {
|
||
apiVersion: 1,
|
||
key: "doubao",
|
||
name: "Doubao Video",
|
||
icon: "Doubao.Color",
|
||
description: {
|
||
en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
|
||
zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
|
||
},
|
||
version: "1.0.0",
|
||
author: { name: "QuantumNous" },
|
||
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
|
||
models: [
|
||
"doubao-seedance-1-0-pro-250528",
|
||
"doubao-seedance-1-0-lite-t2v",
|
||
"doubao-seedance-1-0-lite-i2v",
|
||
"doubao-seedance-1-5-pro-251215",
|
||
"doubao-seedance-2-0-260128",
|
||
"doubao-seedance-2-0-fast-260128",
|
||
"doubao-seedance-2-0-mini-260615",
|
||
"doubao-seedance-2-5-260628",
|
||
],
|
||
fetchMode: "per_task",
|
||
usageSchema: {
|
||
tokens: {
|
||
type: "number",
|
||
unit: "token",
|
||
description: {
|
||
en: "Upstream billing tokens (estimated at submit, actual on completion).",
|
||
zh: "上游计费 token(提交时预估,完成后按实际值)。",
|
||
},
|
||
},
|
||
resolution: {
|
||
enum: ["480p", "720p", "1080p", "4k"],
|
||
description: {
|
||
en: "Output video resolution; Seedance token unit price varies by resolution tier.",
|
||
zh: "输出视频分辨率;Seedance token 单价随分辨率档位变化。",
|
||
},
|
||
},
|
||
video_input: {
|
||
enum: ["none", "video"],
|
||
description: {
|
||
en: "Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.",
|
||
zh: "请求是否包含参考视频输入;Seedance 对视频生视频 token 按更低单价计费。",
|
||
},
|
||
},
|
||
},
|
||
// Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024,
|
||
// 16:9 max-pixel sizes, cross-checked against Volcengine price examples.
|
||
usageExamples: [
|
||
{ label: "480p · 5s", facts: { tokens: 48038, resolution: "480p", video_input: "none" } },
|
||
{ label: "720p · 5s", facts: { tokens: 108000, resolution: "720p", video_input: "none" } },
|
||
{ label: "1080p · 5s", facts: { tokens: 243000, resolution: "1080p", video_input: "none" } },
|
||
{ label: "4k · 5s", facts: { tokens: 972000, resolution: "4k", video_input: "none" } },
|
||
{ label: "720p · 10s", facts: { tokens: 216000, resolution: "720p", video_input: "none" } },
|
||
{ label: "720p · 5s (+4s 输入视频)", facts: { tokens: 194400, resolution: "720p", video_input: "video" } },
|
||
],
|
||
routes: [
|
||
{ method: "POST", path: "/doubao/api/v3/contents/generations/tasks", type: "submit", decode: "createTask", render: "taskCreated" },
|
||
{ method: "GET", path: "/doubao/api/v3/contents/generations/tasks/:task_id", type: "query", render: "taskStatus" },
|
||
],
|
||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||
};
|
||
|
||
function trimmed(value) {
|
||
return String(value || "").trim();
|
||
}
|
||
|
||
function draftTaskIds(content) {
|
||
const ids = [];
|
||
if (!Array.isArray(content)) return ids;
|
||
for (const item of content) {
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||
if (item.type !== "draft_task") continue;
|
||
const draft = item.draft_task;
|
||
if (!draft || typeof draft !== "object" || Array.isArray(draft)) continue;
|
||
const id = trimmed(draft.id);
|
||
if (id) ids.push(id);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function rewriteDraftTaskContent(content, originTasks) {
|
||
if (!Array.isArray(content)) return content;
|
||
return content.map(function (item) {
|
||
if (!item || typeof item !== "object" || Array.isArray(item) || item.type !== "draft_task") return item;
|
||
const draft = item.draft_task;
|
||
if (!draft || typeof draft !== "object" || Array.isArray(draft) || !trimmed(draft.id)) return item;
|
||
const publicId = trimmed(draft.id);
|
||
let upstream = "";
|
||
if (Array.isArray(originTasks)) {
|
||
for (const task of originTasks) {
|
||
if (task && task.taskId === publicId) {
|
||
upstream = trimmed(task.upstreamTaskId);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!upstream) throw new Error("origin task is unavailable");
|
||
return Object.assign({}, item, { draft_task: Object.assign({}, draft, { id: upstream }) });
|
||
});
|
||
}
|
||
|
||
function normalizeResolution(value) {
|
||
const raw = trimmed(value).toLowerCase();
|
||
if (["480p", "720p", "1080p", "4k"].includes(raw)) return raw;
|
||
const parts = raw.replace("*", "x").split("x");
|
||
if (parts.length !== 2) return "720p";
|
||
const max = Math.max(Number(parts[0]), Number(parts[1]));
|
||
if (max >= 3840) return "4k";
|
||
if (max >= 1920) return "1080p";
|
||
if (max >= 1280) return "720p";
|
||
return "480p";
|
||
}
|
||
|
||
function hasVideo(content) {
|
||
return Array.isArray(content) && content.some((item) => item && (item.type === "video_url" || Object.prototype.hasOwnProperty.call(item, "video_url")));
|
||
}
|
||
|
||
// Max-pixel 16:9 dimensions per resolution tier. Used when ratio is absent or
|
||
// adaptive so the submit-time estimate overestimates rather than underestimates.
|
||
// Official Ark formula: tokens = seconds × width × height × 24 / 1024.
|
||
// Video input duration is omitted; extractUsageOnComplete overlays the real bill.
|
||
function resolutionMaxPixels(resolution) {
|
||
if (resolution === "480p") return [854, 480];
|
||
if (resolution === "1080p") return [1920, 1080];
|
||
if (resolution === "4k") return [3840, 2160];
|
||
return [1280, 720];
|
||
}
|
||
|
||
function estimateTokens(seconds, resolution) {
|
||
const dims = resolutionMaxPixels(resolution);
|
||
return (seconds * dims[0] * dims[1] * 24) / 1024;
|
||
}
|
||
|
||
function videoInputRatio(model, resolution, content) {
|
||
const video = hasVideo(content);
|
||
const res = trimmed(resolution).toLowerCase();
|
||
if (model === "doubao-seedance-2-5-260628") {
|
||
if (res === "1080p") return video ? 7.0 / 10.7 : 11.7 / 10.7;
|
||
return video ? 42 / 70 : 1;
|
||
}
|
||
if (model === "doubao-seedance-2-0-260128") {
|
||
if (res === "1080p") return video ? 31 / 46 : 51 / 46;
|
||
if (res === "4k") return video ? 16 / 46 : 26 / 46;
|
||
return video ? 28 / 46 : 1;
|
||
}
|
||
if (model === "doubao-seedance-2-0-fast-260128") return video ? 22 / 37 : 1;
|
||
if (model === "doubao-seedance-2-0-mini-260615") return video ? 14 / 23 : 1;
|
||
return 1;
|
||
}
|
||
|
||
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>';
|
||
}
|
||
|
||
export const native = {
|
||
createTask: function (ctx) {
|
||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||
const body = ctx.body.value;
|
||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
|
||
const model = trimmed(body.model);
|
||
if (!model) throw new Error("model is required");
|
||
if (body.content !== undefined && !Array.isArray(body.content)) throw new Error("content must be an array");
|
||
const content = Array.isArray(body.content) ? body.content : [];
|
||
const texts = [];
|
||
let hasReference = false;
|
||
for (const item of content) {
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||
if (item.type === "text" && typeof item.text === "string") texts.push(item.text);
|
||
else hasReference = true;
|
||
}
|
||
if (!texts.length && !hasReference) throw new Error("content is required");
|
||
const requestBody = {
|
||
model: model,
|
||
prompt: texts
|
||
.filter(function (text) {
|
||
return trimmed(text);
|
||
})
|
||
.join("\n"),
|
||
metadata: body,
|
||
};
|
||
const seconds = Number(body.duration);
|
||
if (Number.isFinite(seconds) && seconds > 0) requestBody.seconds = seconds;
|
||
const intent = { kind: "submit", model: model, action: hasReference ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||
const originTaskIds = draftTaskIds(content);
|
||
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
|
||
return intent;
|
||
},
|
||
taskCreated: function (ctx, task) {
|
||
const data = task.data && typeof task.data === "object" && !Array.isArray(task.data) ? task.data : {};
|
||
return Object.assign({}, data, { id: task.task_id });
|
||
},
|
||
taskStatus: function (ctx, task) {
|
||
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return Object.assign({}, task.data, { id: task.task_id });
|
||
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "running", SUCCESS: "succeeded", FAILURE: "failed" };
|
||
const output = { id: task.task_id, status: statusMap[task.status] || "queued" };
|
||
if (task.fail_reason) output.error = { message: task.fail_reason };
|
||
return output;
|
||
},
|
||
error: function (ctx, error) {
|
||
return { error: { code: error.code, message: error.message } };
|
||
},
|
||
};
|
||
|
||
export function buildSubmitRequest(ctx) {
|
||
const req = ctx.requestBody;
|
||
const metadata = req.metadata || {};
|
||
const body = Object.assign({ model: req.model || "", content: [] }, metadata);
|
||
const imageContent = [];
|
||
const images = Array.isArray(req.images) ? req.images : [];
|
||
for (const url of images) imageContent.push({ type: "image_url", image_url: { url: url } });
|
||
const metadataContent = Array.isArray(body.content) ? body.content : [];
|
||
body.content = imageContent.concat(metadataContent).filter((item) => item && item.type !== "text");
|
||
const hasReference = body.content.length > 0;
|
||
if (trimmed(req.prompt) || !hasReference) body.content.push({ type: "text", text: req.prompt || "" });
|
||
if (Array.isArray(body.content)) body.content = rewriteDraftTaskContent(body.content, ctx.originTasks);
|
||
const seconds = Number.parseInt(req.seconds || "", 10);
|
||
if (seconds > 0) body.duration = seconds;
|
||
body.model = ctx.upstreamModel || body.model;
|
||
return {
|
||
url: ctx.baseUrl + "/api/v3/contents/generations/tasks",
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||
body: body,
|
||
action: hasReference ? "image_to_video" : "text_to_video",
|
||
rewriteModel: body.model,
|
||
};
|
||
}
|
||
|
||
export function parseSubmitResponse(ctx, resp) {
|
||
if (!resp.body || !resp.body.id) throw new Error("task_id is empty");
|
||
return { taskId: resp.body.id, taskData: resp.body };
|
||
}
|
||
|
||
export function extractUsage(ctx) {
|
||
const req = ctx.requestBody || {};
|
||
const metadata = req.metadata || {};
|
||
if (ctx.usagePurpose === "billing_ratios") {
|
||
const ratio = videoInputRatio(ctx.upstreamModel || ctx.model, metadata.resolution, metadata.content);
|
||
return ratio === 1 ? null : { video_input_ratio: ratio };
|
||
}
|
||
let seconds = Number(req.seconds || req.duration || metadata.duration || 0);
|
||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||
const frames = Number(metadata.frames);
|
||
seconds = Number.isFinite(frames) && frames > 0 ? Math.floor(frames / 24) : 15;
|
||
}
|
||
if (seconds <= 0) seconds = 5;
|
||
seconds = Math.min(seconds, 3600);
|
||
const rawResolution = metadata.resolution || req.size;
|
||
const raw = trimmed(rawResolution).toLowerCase();
|
||
const recognized = ["480p", "720p", "1080p", "4k"].includes(raw) || raw.replace("*", "x").split("x").length === 2;
|
||
const resolution = recognized ? normalizeResolution(rawResolution) : "1080p";
|
||
return {
|
||
tokens: estimateTokens(seconds, resolution),
|
||
resolution: resolution,
|
||
video_input: hasVideo(metadata.content) ? "video" : "none",
|
||
};
|
||
}
|
||
|
||
export function buildQueryRequest(ctx) {
|
||
return {
|
||
url: ctx.baseUrl + "/api/v3/contents/generations/tasks/" + ctx.taskId,
|
||
method: "GET",
|
||
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||
};
|
||
}
|
||
|
||
export function parseTaskResult(ctx, body) {
|
||
if (body.status === "pending" || body.status === "queued") return { status: "QUEUED", progress: "10%" };
|
||
if (body.status === "processing" || body.status === "running") return { status: "IN_PROGRESS", progress: "50%" };
|
||
if (body.status === "succeeded") {
|
||
const result = { status: "SUCCESS", progress: "100%", url: body.content && body.content.video_url ? body.content.video_url : "" };
|
||
const usage = body.usage || {};
|
||
const completionTokens = Number(usage.completion_tokens || 0);
|
||
const totalTokens = Number(usage.total_tokens || 0);
|
||
if (Number.isFinite(completionTokens) && completionTokens > 0) result.completionTokens = completionTokens;
|
||
if (Number.isFinite(totalTokens) && totalTokens > 0) result.totalTokens = totalTokens;
|
||
return result;
|
||
}
|
||
if (body.status === "failed" || body.status === "expired" || body.status === "cancelled") {
|
||
const reason = body.error && body.error.message ? body.error.message : body.status;
|
||
return { status: "FAILURE", progress: "100%", reason: reason };
|
||
}
|
||
return { status: "IN_PROGRESS", progress: "30%" };
|
||
}
|
||
|
||
function artifactData(ctx) {
|
||
const data = (ctx && ctx.data) || {};
|
||
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
|
||
return data;
|
||
}
|
||
|
||
export function listArtifacts(task) {
|
||
if (task.status !== "SUCCESS") return [];
|
||
const content = artifactData(task).content || {};
|
||
const artifacts = [];
|
||
if (trimmed(content.video_url)) artifacts.push({ key: "video", type: "video" });
|
||
if (trimmed(content.last_frame_url)) artifacts.push({ key: "last_frame", type: "image", mimeType: "image/png" });
|
||
return artifacts;
|
||
}
|
||
|
||
export function buildContentRequest(ctx) {
|
||
const content = artifactData(ctx).content || {};
|
||
const urls = { video: content.video_url, last_frame: content.last_frame_url };
|
||
const url = trimmed(urls[ctx.artifactKey]);
|
||
if (!url) throw new Error("artifact_not_found");
|
||
return { url: url, method: ctx.clientRequest.method, credentialless: true };
|
||
}
|
||
|
||
export function extractUsageOnComplete(task, taskResult, body) {
|
||
if (!body || body.status !== "succeeded") return {};
|
||
const facts = {};
|
||
const usage = body.usage || {};
|
||
let tokens = Number(usage.completion_tokens);
|
||
if (!Number.isFinite(tokens) || tokens <= 0) tokens = Number(usage.total_tokens);
|
||
if (Number.isFinite(tokens) && tokens > 0) facts.tokens = tokens;
|
||
const content = body.content || {};
|
||
const resolution = trimmed(content.resolution || body.resolution).toLowerCase();
|
||
if (["480p", "720p", "1080p", "4k"].includes(resolution)) facts.resolution = resolution;
|
||
return facts;
|
||
}
|
||
|
||
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);
|
||
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));
|
||
}
|
||
if (!prompt && images.length === 0) throw new Error("input is required");
|
||
const metadata = Object.assign({}, req.metadata || {});
|
||
if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
|
||
else if (req.size && !metadata.resolution) metadata.resolution = normalizeResolution(req.size);
|
||
const requestBody = { model: model, prompt: prompt, metadata: metadata };
|
||
if (images.length) requestBody.images = images;
|
||
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;
|
||
const intent = { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||
const originTaskIds = draftTaskIds(metadata.content);
|
||
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
|
||
return intent;
|
||
},
|
||
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: "doubao" },
|
||
};
|
||
},
|
||
},
|
||
};
|
||
|
||
const legacyRenderers = {
|
||
openai_video: function (task) {
|
||
const data = task.data || {};
|
||
const statusMap = { 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 ? task.properties.origin_model_name || "" : "",
|
||
status: statusMap[task.status] || "unknown",
|
||
progress: Number(String(task.progress || "0").replace("%", "")),
|
||
created_at: task.created_at,
|
||
completed_at: task.updated_at,
|
||
};
|
||
if (data.status === "failed") output.error = { message: data.error ? data.error.message || "" : "", code: data.error ? data.error.code || "" : "" };
|
||
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);
|
||
}
|
||
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 ((ctx.body.files || []).length) throw new Error("Doubao requires image and video references to be URLs inside metadata.content");
|
||
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: 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);
|
||
},
|
||
};
|