diff --git a/README.md b/README.md index 5db8a45..e848e85 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 页面拆分为三个一级模块: - **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。 -- **视频素材库**:上传并审核 MP4/MOV 公网视频,保留历史视频素材,并可直接引用到当前片段。 +- **通用素材库**:上传并审核公网图片或 MP4/MOV 视频,保留历史素材,并可作为当前片段的其他参考图片或参考视频。 - **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。 在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。 @@ -21,7 +21,7 @@ 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` 使用。 +通用素材库同样调用 `POST /v1/assets/upload`:图片传入 `type: "image"`,视频传入 `type: "video"`。审核完成后的 `asset://mat_*` 引用可分别作为当前片段的其他参考图片或 `reference_video` 使用。通用图片用明确的素材用途标记与人物图片分开,避免进入人物组合区。 视频提交后页面每 2.5 秒自动查询一次,最长持续 30 分钟。只有上游明确返回 `failed` 才显示生成失败;30 分钟仍为 `running` 时会保留“仍在生成”状态,不把等待超时误判为失败。任务 ID 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。 diff --git a/public/app.js b/public/app.js index 4838819..5c98f0f 100644 --- a/public/app.js +++ b/public/app.js @@ -9,7 +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_GENERIC_ASSET_KEY = "yingxiangli.pendingGenericAssetReview.v1"; const PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1"; const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup"; const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1"; @@ -32,7 +32,7 @@ const state = { pollTimer: null, reviewStartedAt: 0, assets: [], - videoAssets: [], + genericAssets: [], characters: [], characterDraftReferences: new Set(), activeModule: document.body.dataset.activeModule || "library", @@ -57,8 +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", + "genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus", + "genericAssetHint", "genericAssetResult", "genericAssetResultText", "genericAssetList", "genericAssetHistoryCount", "refreshGenericAssets", ].map((id) => [id, document.getElementById(id)])); const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack]; @@ -70,6 +70,7 @@ let auditFlushTimer = null; setupClientAudit(); setupVideoKeyGate(); +updateGenericAssetForm(); init(); document.querySelectorAll(".module-tab").forEach((button) => { @@ -92,7 +93,7 @@ async function init() { renderScene(); renderStoryboard(); resumePendingReview(); - resumePendingVideoAsset(); + resumePendingGenericAsset(); if (state.config.mockMode || currentVideoKey()) resumePendingVideo(); } catch (error) { showToast(error.message, true); @@ -201,7 +202,8 @@ el.assetForm.addEventListener("submit", async (event) => { } }); -el.videoAssetForm.addEventListener("submit", submitVideoAsset); +el.genericAssetForm.addEventListener("submit", submitGenericAsset); +el.genericAssetType.addEventListener("change", updateGenericAssetForm); el.videoForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -277,7 +279,7 @@ el.videoForm.addEventListener("submit", async (event) => { }); el.refreshAssets.addEventListener("click", loadAssets); -el.refreshVideoAssets.addEventListener("click", loadAssets); +el.refreshGenericAssets.addEventListener("click", loadAssets); el.createCharacterButton.addEventListener("click", openCharacterEditor); el.cancelCharacterButton.addEventListener("click", closeCharacterEditor); el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft); @@ -319,12 +321,12 @@ 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.filter((item) => assetItemType(item) !== "video"); - state.videoAssets = items.filter((item) => assetItemType(item) === "video"); + state.assets = items.filter((item) => assetItemType(item) === "image" && !isGenericAsset(item)); + state.genericAssets = items.filter(isGenericAsset); ingestHistoricalCharacters(state.assets); renderCharacterLibrary(); renderAssetPicker(); - renderVideoAssetLibrary(); + renderGenericAssetLibrary(); el.assetHistoryCount.textContent = String(state.assets.length); el.assetList.innerHTML = ""; if (!state.assets.length) { @@ -360,7 +362,7 @@ async function loadAssets() { ? "客户令牌无效或已过期,请重新输入" : error.message; el.assetList.innerHTML = `
${escapeHtml(message)}
`; - el.videoAssetList.innerHTML = `
${escapeHtml(message)}
`; + el.genericAssetList.innerHTML = `
${escapeHtml(message)}
`; if (!state.config?.mockMode) { el.connectionState.textContent = "素材库连接失败"; el.connectionState.className = "connection blocked"; @@ -373,56 +375,79 @@ 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 = '
暂无已就绪视频素材
'; +function isGenericAsset(item) { + if (assetItemType(item) === "video") return true; + return /^通用图片\s*[·・]/.test(String(item?.purpose || item?.Name || "")); +} + +function genericAssetName(item, sourceUrl) { + return String(item.purpose || item.Name || "").replace(/^通用图片\s*[·・]\s*/, "") || fileNameFromUrl(sourceUrl) || "未命名通用素材"; +} + +function renderGenericAssetLibrary() { + el.genericAssetHistoryCount.textContent = String(state.genericAssets.length); + el.genericAssetList.innerHTML = ""; + if (!state.genericAssets.length) { + el.genericAssetList.innerHTML = '
暂无已就绪通用素材
'; return; } - for (const item of state.videoAssets) { + for (const item of state.genericAssets) { + const type = assetItemType(item); const reference = normalizeAssetReference(item); - const videoUrl = item.original_url || item.URL || ""; - const name = item.purpose || item.Name || fileNameFromUrl(videoUrl) || "未命名视频素材"; + const sourceUrl = item.original_url || item.URL || ""; + const name = genericAssetName(item, sourceUrl); const card = document.createElement("article"); card.className = "video-asset-card"; const preview = document.createElement("div"); preview.className = "video-asset-preview"; - if (videoUrl) { + if (sourceUrl && type === "video") { const video = document.createElement("video"); - video.src = videoUrl; + video.src = sourceUrl; video.controls = true; video.playsInline = true; video.preload = "metadata"; preview.appendChild(video); - } else preview.textContent = "暂无视频预览"; + } else if (sourceUrl) { + const image = document.createElement("img"); + image.src = sourceUrl; + image.alt = name; + image.loading = "lazy"; + preview.appendChild(image); + } else preview.textContent = "暂无素材预览"; const copy = document.createElement("div"); copy.className = "video-asset-copy"; - copy.innerHTML = `${escapeHtml(name)}${escapeHtml(reference)}`; + copy.innerHTML = `${escapeHtml(name)}${type === "video" ? "视频" : "图片"} · ${escapeHtml(reference)}`; 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)); + useButton.addEventListener("click", () => useGenericAssetInActiveShot(reference, name, type)); const copyButton = document.createElement("button"); copyButton.type = "button"; copyButton.textContent = "复制素材引用"; copyButton.addEventListener("click", async () => { try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); } - showToast("视频素材引用已复制"); + showToast("素材引用已复制"); }); actions.append(useButton, copyButton); card.append(preview, copy, actions); - el.videoAssetList.appendChild(card); + el.genericAssetList.appendChild(card); } } -function useVideoAssetInActiveShot(reference, name) { +function useGenericAssetInActiveShot(reference, name, type) { 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"); + if (type === "video") { + const current = parseMediaLines(shot.referenceVideoUrls); + if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true); + shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n"); + } else { + const current = parseMediaLines(shot.extraImageUrls); + const occupied = sceneReferences().length + current.length; + if (!current.includes(reference) && occupied >= 9) return showToast("人物图片和其他参考图片合计最多 9 张", true); + shot.extraImageUrls = [...new Set([...current, reference])].join("\n"); + } saveDramaProject(); switchModule("production"); loadActiveShotIntoForm(); @@ -430,66 +455,76 @@ function useVideoAssetInActiveShot(reference, name) { showToast(`已将「${name}」用于当前片段`); } -async function submitVideoAsset(event) { +function updateGenericAssetForm() { + const isVideo = el.genericAssetType.value === "video"; + el.genericAssetUrl.placeholder = isVideo ? "https://example.com/reference.mp4" : "https://example.com/reference.jpg"; + el.genericAssetHint.textContent = isVideo + ? "支持 MP4 / MOV,必须可通过公网直接访问。" + : "支持 JPG / PNG / WebP 等图片格式,必须可通过公网直接访问。"; +} + +async function submitGenericAsset(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; + const type = el.genericAssetType.value; + const url = el.genericAssetUrl.value.trim(); + const purpose = el.genericAssetPurpose.value.trim(); + setGenericAssetStatus("processing", "审核中"); + el.genericAssetResult.classList.add("hidden"); + el.genericAssetSubmit.disabled = true; try { let result = await api("/api/assets/upload", { method: "POST", - body: JSON.stringify({ type: "video", url, purpose }), + body: JSON.stringify({ type, category: "generic", 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); + if (!taskId) throw new Error("素材接口已受理,但没有返回任务 ID"); + savePendingGenericAsset(taskId, purpose, type); result = await pollAssetTask(taskId, false); } - await finishVideoAssetUpload(result); + await finishGenericAssetUpload(result); } catch (error) { - setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); + setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); showToast(error.message, !error.pending); } finally { - el.videoAssetSubmit.disabled = false; + el.genericAssetSubmit.disabled = false; } } -async function finishVideoAssetUpload(result) { +async function finishGenericAssetUpload(result) { const items = getReviewItems(result); - if (items.length !== 1) throw new Error(result.message || "视频素材接口未返回有效结果"); + 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 || "视频素材未通过审核"); + 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(); + clearPendingGenericAsset(); + setGenericAssetStatus("success", "已入库"); + el.genericAssetResultText.textContent = `素材引用:${reference}`; + el.genericAssetResult.classList.remove("hidden"); + el.genericAssetForm.reset(); + updateGenericAssetForm(); await loadAssets(); - showToast("视频素材已通过审核并入库"); + showToast("通用素材已通过审核并入库"); } -function savePendingVideoAsset(taskId, purpose) { - localStorage.setItem(PENDING_VIDEO_ASSET_KEY, JSON.stringify({ taskId, purpose, startedAt: Date.now() })); +function savePendingGenericAsset(taskId, purpose, type) { + localStorage.setItem(PENDING_GENERIC_ASSET_KEY, JSON.stringify({ taskId, purpose, type, startedAt: Date.now() })); } -function clearPendingVideoAsset() { localStorage.removeItem(PENDING_VIDEO_ASSET_KEY); } +function clearPendingGenericAsset() { localStorage.removeItem(PENDING_GENERIC_ASSET_KEY); } -async function resumePendingVideoAsset() { +async function resumePendingGenericAsset() { let pending; - try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_ASSET_KEY) || "null"); } catch { pending = null; } + try { pending = JSON.parse(localStorage.getItem(PENDING_GENERIC_ASSET_KEY) || "null"); } catch { pending = null; } if (!pending?.taskId) return; - setVideoAssetStatus("processing", "继续查询"); + setGenericAssetStatus("processing", "继续查询"); try { const result = await pollAssetTask(pending.taskId, false); - await finishVideoAssetUpload(result); + await finishGenericAssetUpload(result); } catch (error) { - setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); - if (!error.pending) clearPendingVideoAsset(); + setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); + if (!error.pending) clearPendingGenericAsset(); } } @@ -584,7 +619,7 @@ function bindCharacterToActiveRole(character) { } function switchModule(module) { - if (!["library", "video-library", "production"].includes(module)) return; + if (!["library", "generic-library", "production"].includes(module)) return; state.activeModule = module; document.body.dataset.activeModule = module; document.querySelectorAll('.module-tab').forEach((button) => { @@ -1310,7 +1345,7 @@ function createMediaInputRow(type, value = "") { row.className = "media-url-row"; const input = document.createElement("input"); - input.type = type === "video" ? "text" : "url"; + input.type = "text"; input.value = value; input.placeholder = placeholder; input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接"); @@ -1722,7 +1757,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 setGenericAssetStatus(kind, text) { el.genericAssetStatus.className = `status-badge ${kind}`; el.genericAssetStatus.textContent = text; } function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; } function setStep(active) { document.querySelectorAll(".step").forEach((step) => { diff --git a/public/index.html b/public/index.html index 6526b2d..7f5c901 100644 --- a/public/index.html +++ b/public/index.html @@ -23,8 +23,8 @@ - + 通用素材 + -
0条已就绪视频素材
+
0条已就绪图片/视频素材
- 视频入库要求 + 通用素材入库要求
@@ -159,41 +160,44 @@ -
+
- 视频素材 -

上传视频素材

+ 通用素材 +

上传图片或视频素材

- 未提交 + 未提交
-
-
- + diff --git a/public/styles.css b/public/styles.css index 7cd3cb1..fe6a40f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -60,12 +60,12 @@ main { max-width: 1480px; margin: 0 auto; padding: 24px; } .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="library"] .video-library-only, +body[data-active-module="library"] .generic-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="production"] .generic-library-only, +body[data-active-module="generic-library"] .library-only, +body[data-active-module="generic-library"] .production-only, +body[data-active-module="generic-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); } @@ -155,6 +155,7 @@ body[data-active-module="library"] .asset-library-title { margin-top: 0; } .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-preview img { width: 100%; height: 100%; object-fit: contain; background: #f8fafc; } .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; } diff --git a/server.mjs b/server.mjs index e2e55d0..740cddf 100644 --- a/server.mjs +++ b/server.mjs @@ -131,6 +131,7 @@ async function handleApi(request, response, url) { if (request.method === "POST" && ["/api/assets/review", "/api/assets/upload"].includes(url.pathname)) { const body = await readJson(request); const assetType = String(body.type || "image").toLowerCase(); + const genericUpload = url.pathname === "/api/assets/upload" && body.category === "generic"; if (!["image", "video"].includes(assetType)) throw new ClientError("当前仅支持图片或视频素材"); const assetInputs = (assetType === "video" ? [{ url: body.url, view: "video" }] @@ -138,10 +139,10 @@ async function handleApi(request, response, url) { ? body.images.map((item) => typeof item === "string" ? { url: item } : item) : [{ url: body.url }]) .filter((item) => String(item?.url || "").trim()); - if (assetType === "image" && (assetInputs.length < 1 || assetInputs.length > 3)) { + if (!genericUpload && assetType === "image" && (assetInputs.length < 1 || assetInputs.length > 3)) { throw new ClientError("人物素材图片数量必须为 1–3 张"); } - if (assetType === "video" && assetInputs.length !== 1) throw new ClientError("请填写一条视频素材 URL"); + if ((genericUpload || assetType === "video") && assetInputs.length !== 1) throw new ClientError("请填写一条素材 URL"); for (const item of assetInputs) validatePublicAssetUrl(item.url, assetType); if (mockMode) { @@ -150,11 +151,14 @@ async function handleApi(request, response, url) { const items = assetInputs.map((input, index) => { const id = `mat_demo_${Date.now()}_${index + 1}`; const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${index + 1}`; + const purposeLabel = genericUpload + ? (assetType === "image" ? `通用图片 · ${body.purpose || "图片素材"}` : body.purpose || "视频素材") + : `${body.name || "数字人形象"} · ${viewLabel}`; const rejected = /reject|fail/i.test(input.url); if (!rejected) { mockAssets.unshift({ asset_id: id, - purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`, + purpose: purposeLabel, type: assetType, status: "ready", original_url: input.url, @@ -187,13 +191,16 @@ async function handleApi(request, response, url) { const labels = { front: "正面", side: "侧面", back: "背面" }; const uploaded = await Promise.all(assetInputs.map(async (input, index) => { const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${index + 1}`; + const purposeLabel = genericUpload + ? (assetType === "image" ? `通用图片 · ${body.purpose || "图片素材"}` : body.purpose || "视频素材") + : `${body.name || "数字人形象"} · ${viewLabel}`; const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", { method: "POST", headers: bearerHeaders(customerToken), body: JSON.stringify({ url: input.url, type: assetType, - purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`, + purpose: purposeLabel, }), }); if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) { @@ -559,8 +566,8 @@ function buildVideoPayload(body) { if (imageReferences.length > 9) { throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`); } - if (extraImageReferences.some((item) => !isPublicMediaUrl(item, "image"))) { - throw new ClientError("其他参考图片必须是公网可访问的 http/https 图片 URL"); + if (extraImageReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "image"))) { + throw new ClientError("其他参考图片必须是 asset:// 素材引用或公网图片 URL"); } if (videoReferences.length > 3 || videoReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "video"))) { throw new ClientError("参考视频最多 3 条,且必须是 asset:// 素材引用或公网 MP4/MOV URL"); diff --git a/tests/normalize.test.mjs b/tests/normalize.test.mjs index 4e95f07..7e8484a 100644 --- a/tests/normalize.test.mjs +++ b/tests/normalize.test.mjs @@ -33,10 +33,11 @@ test("生成时长提供 5 到 14 秒全部整数选项", () => { assert.deepEqual(values, [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]); }); -test("人物素材支持任选 1 到 3 张,且提供独立视频素材库", () => { +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"/); + assert.match(html, /data-module="generic-library"/); + assert.match(html, /id="genericAssetForm"/); + assert.match(html, /id="genericAssetType"/); for (const id of ["assetFrontUrl", "assetSideUrl", "assetBackUrl"]) { const input = html.match(new RegExp(`]*>`))?.[0] || ""; assert.ok(input, `缺少 ${id}`);