feat: add flexible character uploads and video asset library
This commit is contained in:
15
README.md
15
README.md
@@ -2,9 +2,10 @@
|
||||
|
||||
用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。
|
||||
|
||||
页面拆分为两个一级模块:
|
||||
页面拆分为三个一级模块:
|
||||
|
||||
- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
|
||||
- **视频素材库**:上传并审核 MP4/MOV 公网视频,保留历史视频素材,并可直接引用到当前片段。
|
||||
- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
|
||||
|
||||
在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
|
||||
@@ -13,13 +14,15 @@
|
||||
|
||||
## 已串联的流程
|
||||
|
||||
1. 输入数字人正面、侧面、背面三张公网图片 URL。
|
||||
2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交三张素材审核。
|
||||
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。
|
||||
1. 输入数字人正面、侧面、背面中任意 1~3 张公网图片 URL。
|
||||
2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交人物素材审核;无需凑齐三张。
|
||||
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至本次图片全部 `ready` 或出现 `failed`。
|
||||
4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
|
||||
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
|
||||
6. 用返回的 `id` 调用 `GET /sd/api/v3/contents/generations/tasks/{task_id}`,直至 `succeeded` 或 `failed`。
|
||||
|
||||
视频素材库同样调用 `POST /v1/assets/upload`,但传入 `type: "video"`;审核完成后的 `asset://mat_*` 引用可作为当前片段的 `reference_video` 使用。
|
||||
|
||||
视频提交后页面每 2.5 秒自动查询一次,最长持续 30 分钟。只有上游明确返回 `failed` 才显示生成失败;30 分钟仍为 `running` 时会保留“仍在生成”状态,不把等待超时误判为失败。任务 ID 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。
|
||||
|
||||
已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。
|
||||
@@ -39,7 +42,7 @@
|
||||
|
||||
单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。每位客户首次访问时输入自己的统一令牌,素材与视频请求均使用该令牌,视频生成费用也归属该令牌。`assetReviewTasks` 与视频任务令牌映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
|
||||
|
||||
页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。
|
||||
页面会分别展示正面、侧面、背面的审核状态与失败原因;未上传的视角保持“未上传”,不会被误判为失败。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选择人物素材时不会解锁视频生成。
|
||||
|
||||
人物库会读取全部历史素材,并把名称形如“人物名 · 正面/侧面/背面”的素材自动组合成人物;也可以手动选择 1–9 张历史素材创建人物组合。场景支持 1–3 个角色,每个角色可绑定一组人物素材,并在刷新后恢复本场角色。
|
||||
|
||||
@@ -89,7 +92,7 @@ npm start
|
||||
## 当前还缺的真实联调材料
|
||||
|
||||
- **客户统一令牌**:形如 `sk-...`,由客户首次打开页面时输入,同时用于素材上传/查询与视频提交/查询。
|
||||
- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。
|
||||
- **数字人人物图片的公网 URL**:正面、侧面、背面任选 1~3 张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。
|
||||
- **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。
|
||||
- **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。
|
||||
|
||||
|
||||
203
public/app.js
203
public/app.js
@@ -9,6 +9,7 @@ window.addEventListener("error", (event) => recordRuntimeError(event.message ||
|
||||
window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection")));
|
||||
|
||||
const PENDING_REVIEW_KEY = "yingxiangli.pendingAssetReview";
|
||||
const PENDING_VIDEO_ASSET_KEY = "yingxiangli.pendingVideoAssetReview.v1";
|
||||
const PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1";
|
||||
const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
|
||||
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
||||
@@ -31,6 +32,7 @@ const state = {
|
||||
pollTimer: null,
|
||||
reviewStartedAt: 0,
|
||||
assets: [],
|
||||
videoAssets: [],
|
||||
characters: [],
|
||||
characterDraftReferences: new Set(),
|
||||
activeModule: document.body.dataset.activeModule || "library",
|
||||
@@ -55,6 +57,8 @@ const el = Object.fromEntries([
|
||||
"addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
|
||||
"versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
|
||||
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
||||
"videoAssetForm", "videoAssetUrl", "videoAssetPurpose", "videoAssetSubmit", "videoAssetStatus",
|
||||
"videoAssetResult", "videoAssetResultText", "videoAssetList", "videoAssetHistoryCount", "refreshVideoAssets",
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
|
||||
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
||||
@@ -88,6 +92,7 @@ async function init() {
|
||||
renderScene();
|
||||
renderStoryboard();
|
||||
resumePendingReview();
|
||||
resumePendingVideoAsset();
|
||||
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
@@ -152,11 +157,17 @@ el.assetName.addEventListener("input", () => {
|
||||
|
||||
el.assetForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const images = [
|
||||
{ view: "front", url: el.assetFrontUrl.value.trim() },
|
||||
{ view: "side", url: el.assetSideUrl.value.trim() },
|
||||
{ view: "back", url: el.assetBackUrl.value.trim() },
|
||||
].filter((item) => item.url);
|
||||
if (!images.length) return showToast("正面、侧面、背面请至少填写一张", true);
|
||||
const role = activeRole();
|
||||
role.name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
|
||||
setAssetStatus("processing", "审核中");
|
||||
state.reviewStartedAt = Date.now();
|
||||
setAllReviewRows("processing", "审核中", "正在提交");
|
||||
preparePersonReviewRows(images);
|
||||
updateReviewElapsed();
|
||||
el.reviewButton.disabled = true;
|
||||
|
||||
@@ -165,11 +176,7 @@ el.assetForm.addEventListener("submit", async (event) => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: role.name,
|
||||
images: [
|
||||
{ view: "front", url: el.assetFrontUrl.value.trim() },
|
||||
{ view: "side", url: el.assetSideUrl.value.trim() },
|
||||
{ view: "back", url: el.assetBackUrl.value.trim() },
|
||||
],
|
||||
images,
|
||||
}),
|
||||
});
|
||||
if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") {
|
||||
@@ -194,6 +201,8 @@ el.assetForm.addEventListener("submit", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
el.videoAssetForm.addEventListener("submit", submitVideoAsset);
|
||||
|
||||
el.videoForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
updateActiveShotFromForm();
|
||||
@@ -268,6 +277,7 @@ el.videoForm.addEventListener("submit", async (event) => {
|
||||
});
|
||||
|
||||
el.refreshAssets.addEventListener("click", loadAssets);
|
||||
el.refreshVideoAssets.addEventListener("click", loadAssets);
|
||||
el.createCharacterButton.addEventListener("click", openCharacterEditor);
|
||||
el.cancelCharacterButton.addEventListener("click", closeCharacterEditor);
|
||||
el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft);
|
||||
@@ -309,17 +319,19 @@ async function loadAssets() {
|
||||
const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length);
|
||||
if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break;
|
||||
}
|
||||
state.assets = items;
|
||||
ingestHistoricalCharacters(items);
|
||||
state.assets = items.filter((item) => assetItemType(item) !== "video");
|
||||
state.videoAssets = items.filter((item) => assetItemType(item) === "video");
|
||||
ingestHistoricalCharacters(state.assets);
|
||||
renderCharacterLibrary();
|
||||
renderAssetPicker();
|
||||
el.assetHistoryCount.textContent = String(items.length);
|
||||
renderVideoAssetLibrary();
|
||||
el.assetHistoryCount.textContent = String(state.assets.length);
|
||||
el.assetList.innerHTML = "";
|
||||
if (!items.length) {
|
||||
el.assetList.innerHTML = '<div class="empty-small">素材库暂无历史素材</div>';
|
||||
if (!state.assets.length) {
|
||||
el.assetList.innerHTML = '<div class="empty-small">素材库暂无人物图片素材</div>';
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
for (const item of state.assets) {
|
||||
const reference = normalizeAssetReference(item);
|
||||
const position = referencePosition(reference);
|
||||
const button = document.createElement("button");
|
||||
@@ -348,6 +360,7 @@ async function loadAssets() {
|
||||
? "客户令牌无效或已过期,请重新输入"
|
||||
: error.message;
|
||||
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||||
el.videoAssetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||||
if (!state.config?.mockMode) {
|
||||
el.connectionState.textContent = "素材库连接失败";
|
||||
el.connectionState.className = "connection blocked";
|
||||
@@ -356,6 +369,130 @@ async function loadAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
function assetItemType(item) {
|
||||
return String(item?.type || item?.asset_type || item?.AssetType || "image").toLowerCase();
|
||||
}
|
||||
|
||||
function renderVideoAssetLibrary() {
|
||||
el.videoAssetHistoryCount.textContent = String(state.videoAssets.length);
|
||||
el.videoAssetList.innerHTML = "";
|
||||
if (!state.videoAssets.length) {
|
||||
el.videoAssetList.innerHTML = '<div class="empty-small">暂无已就绪视频素材</div>';
|
||||
return;
|
||||
}
|
||||
for (const item of state.videoAssets) {
|
||||
const reference = normalizeAssetReference(item);
|
||||
const videoUrl = item.original_url || item.URL || "";
|
||||
const name = item.purpose || item.Name || fileNameFromUrl(videoUrl) || "未命名视频素材";
|
||||
const card = document.createElement("article");
|
||||
card.className = "video-asset-card";
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "video-asset-preview";
|
||||
if (videoUrl) {
|
||||
const video = document.createElement("video");
|
||||
video.src = videoUrl;
|
||||
video.controls = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "metadata";
|
||||
preview.appendChild(video);
|
||||
} else preview.textContent = "暂无视频预览";
|
||||
const copy = document.createElement("div");
|
||||
copy.className = "video-asset-copy";
|
||||
copy.innerHTML = `<strong>${escapeHtml(name)}</strong><span>${escapeHtml(reference)}</span>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "video-asset-actions";
|
||||
const useButton = document.createElement("button");
|
||||
useButton.type = "button";
|
||||
useButton.textContent = "用于当前片段";
|
||||
useButton.addEventListener("click", () => useVideoAssetInActiveShot(reference, name));
|
||||
const copyButton = document.createElement("button");
|
||||
copyButton.type = "button";
|
||||
copyButton.textContent = "复制素材引用";
|
||||
copyButton.addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); }
|
||||
showToast("视频素材引用已复制");
|
||||
});
|
||||
actions.append(useButton, copyButton);
|
||||
card.append(preview, copy, actions);
|
||||
el.videoAssetList.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function useVideoAssetInActiveShot(reference, name) {
|
||||
const shot = activeShot();
|
||||
const current = parseMediaLines(shot.referenceVideoUrls);
|
||||
if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true);
|
||||
shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n");
|
||||
saveDramaProject();
|
||||
switchModule("production");
|
||||
loadActiveShotIntoForm();
|
||||
renderStoryboard();
|
||||
showToast(`已将「${name}」用于当前片段`);
|
||||
}
|
||||
|
||||
async function submitVideoAsset(event) {
|
||||
event.preventDefault();
|
||||
const url = el.videoAssetUrl.value.trim();
|
||||
const purpose = el.videoAssetPurpose.value.trim();
|
||||
setVideoAssetStatus("processing", "审核中");
|
||||
el.videoAssetResult.classList.add("hidden");
|
||||
el.videoAssetSubmit.disabled = true;
|
||||
try {
|
||||
let result = await api("/api/assets/upload", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type: "video", url, purpose }),
|
||||
});
|
||||
if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") {
|
||||
const taskId = result.task_id || result.data?.task_id || result.id;
|
||||
if (!taskId) throw new Error("视频素材接口已受理,但没有返回任务 ID");
|
||||
savePendingVideoAsset(taskId, purpose);
|
||||
result = await pollAssetTask(taskId, false);
|
||||
}
|
||||
await finishVideoAssetUpload(result);
|
||||
} catch (error) {
|
||||
setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
||||
showToast(error.message, !error.pending);
|
||||
} finally {
|
||||
el.videoAssetSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishVideoAssetUpload(result) {
|
||||
const items = getReviewItems(result);
|
||||
if (items.length !== 1) throw new Error(result.message || "视频素材接口未返回有效结果");
|
||||
const item = items[0];
|
||||
if (Number(item.submit_review_status) !== 1) throw new Error(item.error_message || "视频素材未通过审核");
|
||||
const reference = normalizeAssetReference(item);
|
||||
if (!reference) throw new Error("审核通过,但未返回可用的 asset:// ID");
|
||||
clearPendingVideoAsset();
|
||||
setVideoAssetStatus("success", "已入库");
|
||||
el.videoAssetResultText.textContent = `素材引用:${reference}`;
|
||||
el.videoAssetResult.classList.remove("hidden");
|
||||
el.videoAssetForm.reset();
|
||||
await loadAssets();
|
||||
showToast("视频素材已通过审核并入库");
|
||||
}
|
||||
|
||||
function savePendingVideoAsset(taskId, purpose) {
|
||||
localStorage.setItem(PENDING_VIDEO_ASSET_KEY, JSON.stringify({ taskId, purpose, startedAt: Date.now() }));
|
||||
}
|
||||
|
||||
function clearPendingVideoAsset() { localStorage.removeItem(PENDING_VIDEO_ASSET_KEY); }
|
||||
|
||||
async function resumePendingVideoAsset() {
|
||||
let pending;
|
||||
try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_ASSET_KEY) || "null"); } catch { pending = null; }
|
||||
if (!pending?.taskId) return;
|
||||
setVideoAssetStatus("processing", "继续查询");
|
||||
try {
|
||||
const result = await pollAssetTask(pending.taskId, false);
|
||||
await finishVideoAssetUpload(result);
|
||||
} catch (error) {
|
||||
setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
||||
if (!error.pending) clearPendingVideoAsset();
|
||||
}
|
||||
}
|
||||
|
||||
function ingestHistoricalCharacters(items) {
|
||||
const groups = new Map();
|
||||
for (const item of items) {
|
||||
@@ -439,6 +576,7 @@ function bindCharacterToActiveRole(character) {
|
||||
role.name = character.name;
|
||||
role.characterId = character.id;
|
||||
role.references = [...character.references];
|
||||
role.referenceViews = [];
|
||||
if (activeShot() && !activeShot().roleIds.includes(role.id)) activeShot().roleIds.push(role.id);
|
||||
renderScene();
|
||||
if (state.activeModule === "library") switchModule("production");
|
||||
@@ -446,7 +584,7 @@ function bindCharacterToActiveRole(character) {
|
||||
}
|
||||
|
||||
function switchModule(module) {
|
||||
if (!['library', 'production'].includes(module)) return;
|
||||
if (!["library", "video-library", "production"].includes(module)) return;
|
||||
state.activeModule = module;
|
||||
document.body.dataset.activeModule = module;
|
||||
document.querySelectorAll('.module-tab').forEach((button) => {
|
||||
@@ -946,8 +1084,11 @@ function renderActiveRole() {
|
||||
el.activeRoleLabel.textContent = `角色 ${letter} · 人物素材`;
|
||||
el.assetName.value = role.name;
|
||||
reviewRows.forEach((_, index) => {
|
||||
const reference = role.references[index];
|
||||
setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "待选择", reference || `下一张请选择${VIEW_LABELS[index]}图`);
|
||||
const view = ["front", "side", "back"][index];
|
||||
const hasViewMap = Array.isArray(role.referenceViews) && role.referenceViews.length > 0;
|
||||
const mappedIndex = hasViewMap ? role.referenceViews.indexOf(view) : index;
|
||||
const reference = mappedIndex >= 0 ? role.references[mappedIndex] : "";
|
||||
setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "未上传", reference || `${VIEW_LABELS[index]}图为可选素材`);
|
||||
});
|
||||
el.reviewElapsed.textContent = `角色 ${letter} · 已选 ${role.references.length} 张`;
|
||||
}
|
||||
@@ -1131,7 +1272,7 @@ function activeRole() {
|
||||
}
|
||||
|
||||
function createRole(name) {
|
||||
return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [] };
|
||||
return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [], referenceViews: [] };
|
||||
}
|
||||
|
||||
function saveSceneRoles() {
|
||||
@@ -1159,7 +1300,7 @@ function parseMediaLines(value) {
|
||||
|
||||
function mediaInputConfig(type) {
|
||||
return type === "video"
|
||||
? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "https://example.com/reference.mp4" }
|
||||
? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "公网 MP4/MOV 或 asset://mat..." }
|
||||
: { list: el.extraImageInputs, button: el.addExtraImageButton, limit: 9, placeholder: "https://example.com/reference.jpg" };
|
||||
}
|
||||
|
||||
@@ -1169,7 +1310,7 @@ function createMediaInputRow(type, value = "") {
|
||||
row.className = "media-url-row";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "url";
|
||||
input.type = type === "video" ? "text" : "url";
|
||||
input.value = value;
|
||||
input.placeholder = placeholder;
|
||||
input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接");
|
||||
@@ -1251,6 +1392,7 @@ function restoreSceneRoles() {
|
||||
references: Array.isArray(role.references)
|
||||
? role.references.slice(0, 3).map(String).filter((value) => state.config.mockMode || /^asset:\/\/(?:mat_|asset-)/i.test(value))
|
||||
: [],
|
||||
referenceViews: Array.isArray(role.referenceViews) ? role.referenceViews.slice(0, 3).map(String) : [],
|
||||
}));
|
||||
state.activeRoleIndex = Math.min(Number(saved.activeRoleIndex) || 0, state.roles.length - 1);
|
||||
return;
|
||||
@@ -1268,9 +1410,9 @@ function restoreSceneRoles() {
|
||||
async function finishAssetReview(result, roleId, name) {
|
||||
const items = getReviewItems(result);
|
||||
renderReviewItems(items);
|
||||
if (items.length !== 3) throw new Error(result.message || `预期返回 3 张素材,实际返回 ${items.length} 张`);
|
||||
if (items.length < 1 || items.length > 3) throw new Error(result.message || `预期返回 1~3 张素材,实际返回 ${items.length} 张`);
|
||||
const failedItem = items.find((item) => Number(item.submit_review_status) !== 1);
|
||||
if (failedItem) throw new Error(failedItem.error_message || "三视图中有素材未通过审核");
|
||||
if (failedItem) throw new Error(failedItem.error_message || "人物素材中有图片未通过审核");
|
||||
const references = items.map(normalizeAssetReference);
|
||||
if (references.some((reference) => !reference)) throw new Error("审核通过,但有素材未返回可用的 asset:// ID");
|
||||
clearPendingReview();
|
||||
@@ -1278,20 +1420,21 @@ async function finishAssetReview(result, roleId, name) {
|
||||
state.activeRoleIndex = roleIndex;
|
||||
state.roles[roleIndex].name = name;
|
||||
state.roles[roleIndex].references = references;
|
||||
state.roles[roleIndex].referenceViews = items.map((item) => String(item.view || "reference").toLowerCase());
|
||||
upsertCharacter(name, references, "history", true);
|
||||
el.reviewElapsed.textContent = "审核完成";
|
||||
await loadAssets();
|
||||
renderScene();
|
||||
showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的三视图已全部通过审核`);
|
||||
showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的 ${references.length} 张人物素材已全部通过审核`);
|
||||
}
|
||||
|
||||
async function pollAssetTask(taskId) {
|
||||
async function pollAssetTask(taskId, renderItems = true) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
updateReviewElapsed();
|
||||
await delay(3000);
|
||||
const result = await api(`/api/assets/tasks/${encodeURIComponent(taskId)}`);
|
||||
const items = getReviewItems(result);
|
||||
if (items.length) renderReviewItems(items);
|
||||
if (renderItems && items.length) renderReviewItems(items);
|
||||
const status = normalizeAssetTaskStatus(result);
|
||||
if (status === "completed" || status === "failed") return result;
|
||||
}
|
||||
@@ -1345,7 +1488,8 @@ function normalizeAssetTaskStatus(result) {
|
||||
}
|
||||
|
||||
function renderReviewItems(items) {
|
||||
items.slice(0, 3).forEach((item, index) => {
|
||||
items.slice(0, 3).forEach((item, fallbackIndex) => {
|
||||
const index = { front: 0, side: 1, back: 2 }[String(item.view || "").toLowerCase()] ?? fallbackIndex;
|
||||
const passed = Number(item.submit_review_status) === 1;
|
||||
const failed = Number(item.submit_review_status) === 0 || item.error_code || item.error_message;
|
||||
if (passed) setReviewRow(index, "success", "已通过", normalizeAssetReference(item));
|
||||
@@ -1354,6 +1498,14 @@ function renderReviewItems(items) {
|
||||
});
|
||||
}
|
||||
|
||||
function preparePersonReviewRows(images) {
|
||||
reviewRows.forEach((_, index) => setReviewRow(index, "neutral", "未上传", ""));
|
||||
images.forEach((item, fallbackIndex) => {
|
||||
const index = { front: 0, side: 1, back: 2 }[item.view] ?? fallbackIndex;
|
||||
setReviewRow(index, "processing", "审核中", "等待审核结果");
|
||||
});
|
||||
}
|
||||
|
||||
function setReviewRow(index, kind, status, detail = "") {
|
||||
const row = reviewRows[index];
|
||||
if (!row) return;
|
||||
@@ -1478,7 +1630,7 @@ function renderConnection() {
|
||||
if (state.config.mockMode) {
|
||||
el.connectionState.textContent = "Mock 模式 · 可评审完整流程";
|
||||
el.connectionState.className = "connection mock";
|
||||
el.reviewButton.textContent = "模拟提交三视图";
|
||||
el.reviewButton.textContent = "模拟提交人物素材";
|
||||
el.mockHint.classList.remove("hidden");
|
||||
} else if (state.config.assetReady && state.config.videoReady) {
|
||||
el.connectionState.textContent = "真实接口已连接";
|
||||
@@ -1567,6 +1719,7 @@ function updateProgress(value, title) {
|
||||
el.progressBar.style.width = `${safe}%`;
|
||||
}
|
||||
function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; }
|
||||
function setVideoAssetStatus(kind, text) { el.videoAssetStatus.className = `status-badge ${kind}`; el.videoAssetStatus.textContent = text; }
|
||||
function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; }
|
||||
function setStep(active) {
|
||||
document.querySelectorAll(".step").forEach((step) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>营响力|AI 短剧数字人 Demo</title>
|
||||
<link rel="stylesheet" href="./styles.css?v=24" />
|
||||
<link rel="stylesheet" href="./styles.css?v=25" />
|
||||
</head>
|
||||
<body data-active-module="library">
|
||||
<header class="topbar">
|
||||
@@ -21,7 +21,10 @@
|
||||
<main>
|
||||
<nav class="module-tabs" aria-label="功能模块">
|
||||
<button class="module-tab active" type="button" data-module="library">
|
||||
<b>人物素材库</b><span>上传、审核、查看历史素材并组合人物</span>
|
||||
<b>人物素材库</b><span>上传 1~3 张人物图片,审核后组合人物</span>
|
||||
</button>
|
||||
<button class="module-tab" type="button" data-module="video-library">
|
||||
<b>视频素材库</b><span>上传、审核并复用历史视频素材</span>
|
||||
</button>
|
||||
<button class="module-tab" type="button" data-module="production">
|
||||
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
||||
@@ -42,6 +45,7 @@
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="sidebar">
|
||||
<div class="person-library-sidebar">
|
||||
<div class="sidebar-title production-only">
|
||||
<span>本场角色</span>
|
||||
<button class="link-button" id="addRoleButton">+ 添加角色</button>
|
||||
@@ -83,12 +87,29 @@
|
||||
<div class="rule-box library-only">
|
||||
<strong>入库前检查</strong>
|
||||
<ul>
|
||||
<li>正面、侧面、背面分别提供公网 URL</li>
|
||||
<li>正面、侧面、背面任选 1~3 张上传</li>
|
||||
<li>格式:JPG / PNG / WebP 等</li>
|
||||
<li>宽或高 300–6000 px,单张小于 30MB</li>
|
||||
<li>三张全部通过后组成一个角色素材组</li>
|
||||
<li>已上传图片全部通过后组成一个人物素材组</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-library-only video-library-summary">
|
||||
<div class="sidebar-title">
|
||||
<span>视频素材</span>
|
||||
<button class="link-button" type="button" id="refreshVideoAssets">刷新</button>
|
||||
</div>
|
||||
<div class="video-library-count"><strong id="videoAssetHistoryCount">0</strong><span>条已就绪视频素材</span></div>
|
||||
<div class="rule-box">
|
||||
<strong>视频入库要求</strong>
|
||||
<ul>
|
||||
<li>提供公网可访问的视频 URL</li>
|
||||
<li>格式:MP4 / MOV</li>
|
||||
<li>入库审核通过后获得 asset:// 引用</li>
|
||||
<li>可直接用于短剧片段的参考视频</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="panel">
|
||||
@@ -104,17 +125,17 @@
|
||||
<div class="view-grid wide">
|
||||
<label class="view-field">
|
||||
<span><b>01</b> 正面图</span>
|
||||
<input id="assetFrontUrl" type="url" required placeholder="https://example.com/front.png" />
|
||||
<input id="assetFrontUrl" type="url" placeholder="https://example.com/front.png" />
|
||||
<small>五官与服装正面细节</small>
|
||||
</label>
|
||||
<label class="view-field">
|
||||
<span><b>02</b> 侧面图</span>
|
||||
<input id="assetSideUrl" type="url" required placeholder="https://example.com/side.png" />
|
||||
<input id="assetSideUrl" type="url" placeholder="https://example.com/side.png" />
|
||||
<small>轮廓、发型和体态</small>
|
||||
</label>
|
||||
<label class="view-field">
|
||||
<span><b>03</b> 背面图</span>
|
||||
<input id="assetBackUrl" type="url" required placeholder="https://example.com/back.png" />
|
||||
<input id="assetBackUrl" type="url" placeholder="https://example.com/back.png" />
|
||||
<small>背部服装与发型细节</small>
|
||||
</label>
|
||||
</div>
|
||||
@@ -125,7 +146,7 @@
|
||||
</label>
|
||||
<div class="field action-field">
|
||||
<span> </span>
|
||||
<button class="primary" type="submit" id="reviewButton">提交三视图审核</button>
|
||||
<button class="primary" type="submit" id="reviewButton">提交人物素材审核</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -133,11 +154,48 @@
|
||||
<div class="result-icon">✓</div>
|
||||
<div>
|
||||
<strong>人物素材已入库并完成组合</strong>
|
||||
<p>审核通过的图片会绑定为一个可复用人物,也可在人物库中重新组合。</p>
|
||||
<p>已上传的 1~3 张图片全部通过后,会绑定为一个可复用人物。</p>
|
||||
<div class="reference-list" id="assetReferences"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-header video-library-only">
|
||||
<div>
|
||||
<span class="section-kicker">视频素材</span>
|
||||
<h2>上传视频素材</h2>
|
||||
</div>
|
||||
<span class="status-badge neutral" id="videoAssetStatus">未提交</span>
|
||||
</div>
|
||||
|
||||
<form id="videoAssetForm" class="form-grid video-library-only">
|
||||
<label class="field wide">
|
||||
<span>视频公网 URL</span>
|
||||
<input id="videoAssetUrl" type="url" required placeholder="https://example.com/reference.mp4" />
|
||||
<small>支持 MP4 / MOV,必须可通过公网直接访问。</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>素材名称</span>
|
||||
<input id="videoAssetPurpose" required placeholder="例如:电梯口上一片段 / 角色走路参考" />
|
||||
</label>
|
||||
<div class="field action-field">
|
||||
<span> </span>
|
||||
<button class="primary" type="submit" id="videoAssetSubmit">提交视频素材审核</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="result-card hidden video-library-only" id="videoAssetResult">
|
||||
<div class="result-icon">✓</div>
|
||||
<div><strong>视频素材已通过审核</strong><p id="videoAssetResultText">可在下方历史素材中查看并用于短剧生产。</p></div>
|
||||
</div>
|
||||
|
||||
<section class="video-asset-history video-library-only">
|
||||
<div class="video-asset-history-head">
|
||||
<div><span class="section-kicker">历史素材</span><h3>已就绪视频</h3></div>
|
||||
<span>点击“用于当前片段”可直接带入短剧生产</span>
|
||||
</div>
|
||||
<div class="video-asset-grid" id="videoAssetList"><div class="empty-small">暂无已就绪视频素材</div></div>
|
||||
</section>
|
||||
|
||||
<div class="storyboard production-only">
|
||||
<label class="field continuity-choice">
|
||||
<span>与上一片段的衔接</span>
|
||||
@@ -178,7 +236,7 @@
|
||||
<button class="media-add-button" type="button" id="addReferenceVideoButton">+ 添加视频</button>
|
||||
</div>
|
||||
<div class="media-url-list" id="referenceVideoInputs"></div>
|
||||
<small>手动补充 reference_video;选择“引用上一片段”后系统会自动加入,无需重复填写。</small>
|
||||
<small>可填写公网 MP4/MOV,或从视频素材库选择 asset:// 引用;选择“引用上一片段”后系统会自动加入。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-quota wide" id="inputQuota">人物图片 0 张 · 其他图片 0 张 · 图片合计 0/9 · 视频 0 条</div>
|
||||
@@ -289,6 +347,6 @@
|
||||
</div>
|
||||
|
||||
<div class="toast hidden" id="toast"></div>
|
||||
<script src="./app.js?v=30"></script>
|
||||
<script src="./app.js?v=31"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -53,14 +53,19 @@ button { cursor: pointer; }
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 24px; }
|
||||
.panel, .sidebar { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
|
||||
|
||||
.module-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
|
||||
.module-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
|
||||
.module-tab { min-height: 72px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 14px 18px; display: grid; gap: 4px; text-align: left; color: var(--muted); }
|
||||
.module-tab b { color: var(--ink); font-size: 16px; }
|
||||
.module-tab span { font-size: 12px; }
|
||||
.module-tab.active { border-color: var(--blue); background: var(--blue-soft); box-shadow: 0 0 0 2px rgba(41,87,232,.06); }
|
||||
.module-tab.active b { color: var(--blue); }
|
||||
body[data-active-module="library"] .production-only,
|
||||
body[data-active-module="production"] .library-only { display: none !important; }
|
||||
body[data-active-module="library"] .video-library-only,
|
||||
body[data-active-module="production"] .library-only,
|
||||
body[data-active-module="production"] .video-library-only,
|
||||
body[data-active-module="video-library"] .library-only,
|
||||
body[data-active-module="video-library"] .production-only,
|
||||
body[data-active-module="video-library"] .person-library-sidebar { display: none !important; }
|
||||
body[data-active-module="library"] .asset-library-title { margin-top: 0; }
|
||||
|
||||
.shot-overview { margin: -2px 0 18px; padding: 14px 16px 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
|
||||
@@ -138,6 +143,23 @@ body[data-active-module="library"] .asset-library-title { margin-top: 0; }
|
||||
.asset-item strong { font-size: 13px; }
|
||||
.asset-item span { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-small { color: var(--muted); font-size: 13px; padding: 18px 10px; text-align: center; border: 1px dashed #ccd3df; border-radius: 10px; }
|
||||
.video-library-count { display: grid; gap: 3px; margin: 4px 0 18px; padding: 16px; border-radius: 12px; background: var(--blue-soft); }
|
||||
.video-library-count strong { color: var(--blue); font-size: 28px; }
|
||||
.video-library-count span { color: var(--muted); font-size: 12px; }
|
||||
.video-asset-history { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
.video-asset-history-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 14px; }
|
||||
.video-asset-history-head h3 { margin: 4px 0 0; font-size: 18px; }
|
||||
.video-asset-history-head > span { color: var(--muted); font-size: 12px; }
|
||||
.video-asset-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.video-asset-grid > .empty-small { grid-column: 1 / -1; }
|
||||
.video-asset-card { overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; }
|
||||
.video-asset-preview { aspect-ratio: 16 / 9; display: grid; place-items: center; background: #111827; }
|
||||
.video-asset-preview video { width: 100%; height: 100%; object-fit: contain; }
|
||||
.video-asset-copy { display: grid; gap: 5px; padding: 11px 12px 8px; }
|
||||
.video-asset-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.video-asset-copy span { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.video-asset-actions { display: flex; gap: 8px; padding: 0 12px 12px; }
|
||||
.video-asset-actions button { flex: 1; border: 1px solid #cbd4e5; border-radius: 8px; background: white; padding: 7px 9px; color: var(--blue); font-size: 11px; }
|
||||
.rule-box { margin-top: 18px; padding: 14px; border-radius: 12px; background: #f7f9fd; font-size: 12px; }
|
||||
.rule-box ul { padding-left: 18px; margin: 8px 0 0; color: var(--muted); line-height: 1.7; }
|
||||
|
||||
@@ -282,5 +304,6 @@ hr { border: 0; border-top: 1px solid var(--line); margin: 26px 0 0; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.view-grid { grid-template-columns: 1fr; }
|
||||
.multimodal-grid { grid-template-columns: 1fr; }
|
||||
.video-asset-grid { grid-template-columns: 1fr; }
|
||||
.field.wide { grid-column: auto; }
|
||||
}
|
||||
|
||||
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}` : "",
|
||||
|
||||
@@ -32,3 +32,14 @@ test("生成时长提供 5 到 14 秒全部整数选项", () => {
|
||||
const values = [...durationSelect.matchAll(/<option value="(\d+)">/g)].map((match) => Number(match[1]));
|
||||
assert.deepEqual(values, [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
|
||||
});
|
||||
|
||||
test("人物素材支持任选 1 到 3 张,且提供独立视频素材库", () => {
|
||||
const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
|
||||
assert.match(html, /data-module="video-library"/);
|
||||
assert.match(html, /id="videoAssetForm"/);
|
||||
for (const id of ["assetFrontUrl", "assetSideUrl", "assetBackUrl"]) {
|
||||
const input = html.match(new RegExp(`<input id="${id}"[^>]*>`))?.[0] || "";
|
||||
assert.ok(input, `缺少 ${id}`);
|
||||
assert.doesNotMatch(input, /\srequired(?:\s|>)/);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user