feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)

This commit is contained in:
Calcium-Ion
2026-08-29 18:51:57 +08:00
committed by GitHub
parent 7037ac15bd
commit eb48396d5f
336 changed files with 52333 additions and 6369 deletions
+402
View File
@@ -0,0 +1,402 @@
export const meta = {
apiVersion: 1,
key: "hailuo",
name: "Hailuo Video",
icon: "Hailuo.Color",
description: {
en: "MiniMax Hailuo video generation (text-to-video and image-to-video)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频)",
},
version: "1.0.0",
author: { name: "QuantumNous" },
channelTypes: [35],
models: [
"MiniMax-Hailuo-2.3",
"MiniMax-Hailuo-2.3-Fast",
"MiniMax-Hailuo-02",
"T2V-01-Director",
"T2V-01",
"I2V-01-Director",
"I2V-01-live",
"I2V-01",
"S2V-01",
],
fetchMode: "per_task",
usageSchema: {
seconds: {
type: "number",
unit: "second",
description: {
en: "Requested video duration in seconds. Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.",
zh: "请求的视频时长,单位为秒。Hailuo 2.3/02/2.3-Fast 允许 6 或 1001 系列允许 6。",
},
},
resolution: {
enum: ["512P", "768P", "720P", "1080P"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
},
},
usageExamples: [
{ label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
{ label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
{ label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function isModernHailuo(model) {
return model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02";
}
function defaultResolution(model) {
if (model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02") return "768P";
return "720P";
}
function resolutionFor(size, model) {
const value = String(size || "");
if (value.includes("1080")) return "1080P";
if (value.includes("768")) return "768P";
if (value.includes("720")) return isModernHailuo(model) ? "768P" : "720P";
if (value.includes("512")) return "512P";
return defaultResolution(model);
}
function outboundDuration(req) {
const n = Number(req && req.duration);
if (Number.isFinite(n) && n > 0) return n;
return 6;
}
function outboundResolution(req, model) {
if (req && req.resolution) return resolutionFor(req.resolution, model);
const metadata = (req && req.metadata) || {};
if (metadata.resolution) return resolutionFor(metadata.resolution, model);
if (req && req.size) return resolutionFor(req.size, model);
return defaultResolution(model);
}
function hasHailuoImage(req, hasInputReferenceFile) {
if (hasInputReferenceFile) return true;
const metadata = (req && req.metadata) || {};
return Boolean(
trimmed(req && req.input_reference) ||
trimmed(req && req.image) ||
(Array.isArray(req && req.images) && req.images.length) ||
metadata.first_frame_image ||
metadata.last_frame_image ||
metadata.subject_reference
);
}
// Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证).
// Keep those models permissive: duration 6 only, resolution optional.
function validateHailuoCombo(model, duration, resolution, hasImage) {
if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
}
if (!isModernHailuo(model)) {
if (duration !== undefined && Number(duration) !== 6) throw new Error(model + " duration must be 6");
return;
}
const n = duration === undefined ? 6 : Number(duration);
if (n !== 6 && n !== 10) throw new Error(model + " duration must be 6 or 10");
if (n === 10) {
if (model === "MiniMax-Hailuo-02" && hasImage) {
if (resolution !== "768P" && resolution !== "512P") throw new Error("MiniMax-Hailuo-02 duration 10 only allows resolution 768P or 512P");
return;
}
if (resolution !== "768P") throw new Error(model + " duration 10 only allows resolution 768P");
return;
}
const allowed = model === "MiniMax-Hailuo-02" && hasImage ? ["512P", "768P", "1080P"] : ["768P", "1080P"];
if (allowed.indexOf(resolution) < 0) throw new Error(model + " duration 6 only allows resolution " + allowed.join(" or "));
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody || {};
const model = ctx.upstreamModel;
const metadata = req.metadata || {};
const body = {
model: model,
prompt: req.prompt || undefined,
duration: outboundDuration(req),
resolution: outboundResolution(req, model),
};
["prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark", "first_frame_image", "last_frame_image", "subject_reference"].forEach(
function (key) {
if (metadata[key] !== undefined && metadata[key] !== null) body[key] = metadata[key];
}
);
return {
url: ctx.baseUrl + "/v1/video_generation",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: body,
action: hasHailuoImage(req, false) ? "image_to_video" : "text_to_video",
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
const base = body.base_resp || {};
if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
if (!body.task_id) throw new Error("missing task_id");
return { taskId: body.task_id, taskData: body };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = ctx.upstreamModel || req.model;
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
}
export function buildQueryRequest(ctx) {
return {
url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId),
method: "GET",
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
const base = body.base_resp || {};
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
const status = statuses[body.status] || "IN_PROGRESS";
const progress = status === "SUCCESS" || status === "FAILURE" ? "100%" : body.status === "Processing" ? "50%" : "30%";
const reason = base.status_code !== 0 ? base.status_msg || "" : status === "FAILURE" ? "task failed" : "";
return { code: base.status_code || 0, status: status, progress: progress, reason: reason };
}
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;
}
function artifactFileID(ctx) {
return trimmed(artifactData(ctx).file_id);
}
export function listArtifacts(task) {
return task.status === "SUCCESS" && artifactFileID(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const fileID = artifactFileID(ctx);
if (!fileID) throw new Error("artifact_not_found");
return {
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
method: ctx.clientRequest.method,
headers: { Accept: "video/*", Authorization: "Bearer " + ctx.apiKey },
};
}
export function extractUsageOnComplete(_task, _taskResult, body) {
const width = Number((body || {}).video_width || 0);
const height = Number((body || {}).video_height || 0);
if (!(width > 0) || !(height > 0)) return null;
return { resolution: resolutionFor(width + "x" + height, "") };
}
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 (images.length && !metadata.first_frame_image) metadata.first_frame_image = images[0];
if (images.length > 1 && !metadata.last_frame_image) metadata.last_frame_image = images[1];
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
else if (Object.prototype.hasOwnProperty.call(req, "resolution")) requestBody.size = req.resolution;
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: "hailuo" },
};
},
},
};
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 && task.properties.origin_model_name ? task.properties.origin_model_name : "",
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (task.updated_at) output.completed_at = task.updated_at;
if (task.data && task.data.base_resp && task.data.base_resp.status_code !== 0) {
output.error = { message: task.data.base_resp.status_msg, code: String(task.data.base_resp.status_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");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
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];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
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) req.duration = Number(seconds);
if (hasInputReferenceFile) {
req.metadata = Object.assign({}, req.metadata || {}, {
first_frame_image: { __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 20971520 },
});
} else {
const image = trimmed(req.input_reference || req.image);
if (image) {
req.metadata = Object.assign({}, req.metadata || {});
if (!req.metadata.first_frame_image) req.metadata.first_frame_image = image;
}
}
const hasImage = hasHailuoImage(req, hasInputReferenceFile);
const duration = req.duration === undefined ? undefined : Number(req.duration);
validateHailuoCombo(ctx.model, duration, outboundResolution(req, ctx.model), hasImage);
return {
kind: "submit",
model: ctx.model,
action: hasImage ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};