feat: add flexible character uploads and video asset library

This commit is contained in:
xuejianwu
2026-08-05 16:27:16 +08:00
parent 77dc339f87
commit 93b12bf739
6 changed files with 331 additions and 71 deletions

View File

@@ -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("素材图片数量必须为 19 张");
: [{ url: body.url }])
.filter((item) => String(item?.url || "").trim());
if (assetType === "image" && (assetInputs.length < 1 || assetInputs.length > 3)) {
throw new ClientError("人物素材图片数量必须为 13 张");
}
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}` : "",