feat: add flexible character uploads and video asset library
This commit is contained in:
66
server.mjs
66
server.mjs
@@ -128,44 +128,50 @@ async function handleApi(request, response, url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/assets/review") {
|
||||
if (request.method === "POST" && ["/api/assets/review", "/api/assets/upload"].includes(url.pathname)) {
|
||||
const body = await readJson(request);
|
||||
const imageInputs = Array.isArray(body.images)
|
||||
const assetType = String(body.type || "image").toLowerCase();
|
||||
if (!["image", "video"].includes(assetType)) throw new ClientError("当前仅支持图片或视频素材");
|
||||
const assetInputs = (assetType === "video"
|
||||
? [{ url: body.url, view: "video" }]
|
||||
: Array.isArray(body.images)
|
||||
? body.images.map((item) => typeof item === "string" ? { url: item } : item)
|
||||
: [{ url: body.url }];
|
||||
if (imageInputs.length < 1 || imageInputs.length > 9) {
|
||||
throw new ClientError("素材图片数量必须为 1–9 张");
|
||||
: [{ url: body.url }])
|
||||
.filter((item) => String(item?.url || "").trim());
|
||||
if (assetType === "image" && (assetInputs.length < 1 || assetInputs.length > 3)) {
|
||||
throw new ClientError("人物素材图片数量必须为 1–3 张");
|
||||
}
|
||||
for (const item of imageInputs) validatePublicUrl(item.url);
|
||||
if (assetType === "video" && assetInputs.length !== 1) throw new ClientError("请填写一条视频素材 URL");
|
||||
for (const item of assetInputs) validatePublicAssetUrl(item.url, assetType);
|
||||
|
||||
if (mockMode) {
|
||||
const now = new Date().toISOString();
|
||||
const labels = { front: "正面", side: "侧面", back: "背面" };
|
||||
const items = imageInputs.map((input, index) => {
|
||||
const items = assetInputs.map((input, index) => {
|
||||
const id = `mat_demo_${Date.now()}_${index + 1}`;
|
||||
const viewLabel = labels[input.view] || `参考图${index + 1}`;
|
||||
const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${index + 1}`;
|
||||
const rejected = /reject|fail/i.test(input.url);
|
||||
if (!rejected) {
|
||||
mockAssets.unshift({
|
||||
asset_id: id,
|
||||
purpose: `${body.name || "数字人形象"} · ${viewLabel}`,
|
||||
type: "image",
|
||||
purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`,
|
||||
type: assetType,
|
||||
status: "ready",
|
||||
original_url: input.url,
|
||||
View: input.view || "reference",
|
||||
View: input.view || (assetType === "video" ? "video" : "reference"),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
});
|
||||
}
|
||||
return {
|
||||
source_url: input.url,
|
||||
view: input.view || "reference",
|
||||
asset_type: "Image",
|
||||
view: input.view || (assetType === "video" ? "video" : "reference"),
|
||||
asset_type: assetType === "video" ? "Video" : "Image",
|
||||
submit_review_status: rejected ? 0 : 1,
|
||||
downstream_asset_id: rejected ? "" : id,
|
||||
asset_url: rejected ? "" : `asset://${id}`,
|
||||
error_code: rejected ? "MOCK_REVIEW_REJECTED" : "",
|
||||
error_message: rejected ? `${viewLabel}图未通过素材审核(Mock 驳回)` : "",
|
||||
error_message: rejected ? `${viewLabel}未通过素材审核(Mock 驳回)` : "",
|
||||
};
|
||||
});
|
||||
sendJson(response, 200, {
|
||||
@@ -179,15 +185,15 @@ async function handleApi(request, response, url) {
|
||||
|
||||
const customerToken = requireCustomerToken(request);
|
||||
const labels = { front: "正面", side: "侧面", back: "背面" };
|
||||
const uploaded = await Promise.all(imageInputs.map(async (input, index) => {
|
||||
const viewLabel = labels[input.view] || `参考图${index + 1}`;
|
||||
const uploaded = await Promise.all(assetInputs.map(async (input, index) => {
|
||||
const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${index + 1}`;
|
||||
const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
|
||||
method: "POST",
|
||||
headers: bearerHeaders(customerToken),
|
||||
body: JSON.stringify({
|
||||
url: input.url,
|
||||
type: "image",
|
||||
purpose: `${body.name || "数字人形象"} · ${viewLabel}`,
|
||||
type: assetType,
|
||||
purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`,
|
||||
}),
|
||||
});
|
||||
if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) {
|
||||
@@ -195,7 +201,8 @@ async function handleApi(request, response, url) {
|
||||
}
|
||||
return {
|
||||
sourceUrl: input.url,
|
||||
view: input.view || "reference",
|
||||
view: input.view || (assetType === "video" ? "video" : "reference"),
|
||||
assetType,
|
||||
assetId: upstream.body.data.asset_id,
|
||||
groupId: upstream.body.data.group_id || "",
|
||||
status: String(upstream.body.data.status || "pending").toLowerCase(),
|
||||
@@ -502,16 +509,17 @@ async function readJson(request) {
|
||||
}
|
||||
}
|
||||
|
||||
function validatePublicUrl(value) {
|
||||
function validatePublicAssetUrl(value, type = "image") {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new ClientError("请输入公网可访问的图片 URL");
|
||||
throw new ClientError(`请输入公网可访问的${type === "video" ? "视频" : "图片"} URL`);
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new ClientError("素材仅支持 http/https URL");
|
||||
if (!/\.(jpe?g|png|webp|bmp|tiff?|gif|heic|heif)(\?.*)?$/i.test(url.href)) {
|
||||
throw new ClientError("URL 必须带受支持的图片扩展名,例如 .png 或 .jpg");
|
||||
const pattern = type === "video" ? /\.(mp4|mov)(\?.*)?$/i : /\.(jpe?g|png|webp|bmp|tiff?|gif|heic|heif)(\?.*)?$/i;
|
||||
if (!pattern.test(url.href)) {
|
||||
throw new ClientError(type === "video" ? "视频 URL 必须带 .mp4 或 .mov 扩展名" : "URL 必须带受支持的图片扩展名,例如 .png 或 .jpg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,8 +562,8 @@ function buildVideoPayload(body) {
|
||||
if (extraImageReferences.some((item) => !isPublicMediaUrl(item, "image"))) {
|
||||
throw new ClientError("其他参考图片必须是公网可访问的 http/https 图片 URL");
|
||||
}
|
||||
if (videoReferences.length > 3 || videoReferences.some((item) => !isPublicMediaUrl(item, "video"))) {
|
||||
throw new ClientError("参考视频最多 3 条,且必须是公网可访问的 MP4/MOV URL");
|
||||
if (videoReferences.length > 3 || videoReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "video"))) {
|
||||
throw new ClientError("参考视频最多 3 条,且必须是 asset:// 素材引用或公网 MP4/MOV URL");
|
||||
}
|
||||
if (!String(body.prompt || "").trim()) throw new ClientError("请填写分镜提示词");
|
||||
const duration = Number(body.duration);
|
||||
@@ -596,6 +604,10 @@ function isPublicMediaUrl(value, kind) {
|
||||
}
|
||||
}
|
||||
|
||||
function isAssetReference(value) {
|
||||
return /^asset:\/\/(?:mat_|asset-)/i.test(String(value || ""));
|
||||
}
|
||||
|
||||
function resolveVideoModel(value = "") {
|
||||
const aliases = {
|
||||
"seed-2": "doubao-seedance-2-0-260128",
|
||||
@@ -632,7 +644,7 @@ async function refreshAssetReviewTask(task, customerToken) {
|
||||
return;
|
||||
}
|
||||
item.status = String(upstream.body?.data?.status || item.status || "pending").toLowerCase();
|
||||
item.errorMessage = upstream.body?.data?.error_message || upstream.body?.message || "";
|
||||
item.errorMessage = upstream.body?.data?.failure_reason || upstream.body?.data?.error_message || upstream.body?.message || "";
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -646,7 +658,7 @@ function formatAssetReviewTask(task) {
|
||||
items: task.items.map((item) => ({
|
||||
source_url: item.sourceUrl,
|
||||
view: item.view,
|
||||
asset_type: "Image",
|
||||
asset_type: item.assetType === "video" ? "Video" : "Image",
|
||||
submit_review_status: item.status === "ready" ? 1 : item.status === "failed" ? 0 : null,
|
||||
downstream_asset_id: item.assetId,
|
||||
asset_url: item.status === "ready" ? `asset://${item.assetId}` : "",
|
||||
|
||||
Reference in New Issue
Block a user