feat: expand video library into general asset library
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
页面拆分为三个一级模块:
|
页面拆分为三个一级模块:
|
||||||
|
|
||||||
- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
|
- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
|
||||||
- **视频素材库**:上传并审核 MP4/MOV 公网视频,保留历史视频素材,并可直接引用到当前片段。
|
- **通用素材库**:上传并审核公网图片或 MP4/MOV 视频,保留历史素材,并可作为当前片段的其他参考图片或参考视频。
|
||||||
- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
|
- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
|
||||||
|
|
||||||
在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
|
在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
|
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
|
||||||
6. 用返回的 `id` 调用 `GET /sd/api/v3/contents/generations/tasks/{task_id}`,直至 `succeeded` 或 `failed`。
|
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 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。
|
视频提交后页面每 2.5 秒自动查询一次,最长持续 30 分钟。只有上游明确返回 `failed` 才显示生成失败;30 分钟仍为 `running` 时会保留“仍在生成”状态,不把等待超时误判为失败。任务 ID 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。
|
||||||
|
|
||||||
|
|||||||
161
public/app.js
161
public/app.js
@@ -9,7 +9,7 @@ window.addEventListener("error", (event) => recordRuntimeError(event.message ||
|
|||||||
window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection")));
|
window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection")));
|
||||||
|
|
||||||
const PENDING_REVIEW_KEY = "yingxiangli.pendingAssetReview";
|
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 PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1";
|
||||||
const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
|
const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
|
||||||
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
||||||
@@ -32,7 +32,7 @@ const state = {
|
|||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
reviewStartedAt: 0,
|
reviewStartedAt: 0,
|
||||||
assets: [],
|
assets: [],
|
||||||
videoAssets: [],
|
genericAssets: [],
|
||||||
characters: [],
|
characters: [],
|
||||||
characterDraftReferences: new Set(),
|
characterDraftReferences: new Set(),
|
||||||
activeModule: document.body.dataset.activeModule || "library",
|
activeModule: document.body.dataset.activeModule || "library",
|
||||||
@@ -57,8 +57,8 @@ const el = Object.fromEntries([
|
|||||||
"addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
|
"addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
|
||||||
"versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
|
"versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
|
||||||
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
||||||
"videoAssetForm", "videoAssetUrl", "videoAssetPurpose", "videoAssetSubmit", "videoAssetStatus",
|
"genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus",
|
||||||
"videoAssetResult", "videoAssetResultText", "videoAssetList", "videoAssetHistoryCount", "refreshVideoAssets",
|
"genericAssetHint", "genericAssetResult", "genericAssetResultText", "genericAssetList", "genericAssetHistoryCount", "refreshGenericAssets",
|
||||||
].map((id) => [id, document.getElementById(id)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
|
|
||||||
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
||||||
@@ -70,6 +70,7 @@ let auditFlushTimer = null;
|
|||||||
|
|
||||||
setupClientAudit();
|
setupClientAudit();
|
||||||
setupVideoKeyGate();
|
setupVideoKeyGate();
|
||||||
|
updateGenericAssetForm();
|
||||||
init();
|
init();
|
||||||
|
|
||||||
document.querySelectorAll(".module-tab").forEach((button) => {
|
document.querySelectorAll(".module-tab").forEach((button) => {
|
||||||
@@ -92,7 +93,7 @@ async function init() {
|
|||||||
renderScene();
|
renderScene();
|
||||||
renderStoryboard();
|
renderStoryboard();
|
||||||
resumePendingReview();
|
resumePendingReview();
|
||||||
resumePendingVideoAsset();
|
resumePendingGenericAsset();
|
||||||
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, true);
|
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) => {
|
el.videoForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -277,7 +279,7 @@ el.videoForm.addEventListener("submit", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.refreshAssets.addEventListener("click", loadAssets);
|
el.refreshAssets.addEventListener("click", loadAssets);
|
||||||
el.refreshVideoAssets.addEventListener("click", loadAssets);
|
el.refreshGenericAssets.addEventListener("click", loadAssets);
|
||||||
el.createCharacterButton.addEventListener("click", openCharacterEditor);
|
el.createCharacterButton.addEventListener("click", openCharacterEditor);
|
||||||
el.cancelCharacterButton.addEventListener("click", closeCharacterEditor);
|
el.cancelCharacterButton.addEventListener("click", closeCharacterEditor);
|
||||||
el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft);
|
el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft);
|
||||||
@@ -319,12 +321,12 @@ async function loadAssets() {
|
|||||||
const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length);
|
const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length);
|
||||||
if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break;
|
if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break;
|
||||||
}
|
}
|
||||||
state.assets = items.filter((item) => assetItemType(item) !== "video");
|
state.assets = items.filter((item) => assetItemType(item) === "image" && !isGenericAsset(item));
|
||||||
state.videoAssets = items.filter((item) => assetItemType(item) === "video");
|
state.genericAssets = items.filter(isGenericAsset);
|
||||||
ingestHistoricalCharacters(state.assets);
|
ingestHistoricalCharacters(state.assets);
|
||||||
renderCharacterLibrary();
|
renderCharacterLibrary();
|
||||||
renderAssetPicker();
|
renderAssetPicker();
|
||||||
renderVideoAssetLibrary();
|
renderGenericAssetLibrary();
|
||||||
el.assetHistoryCount.textContent = String(state.assets.length);
|
el.assetHistoryCount.textContent = String(state.assets.length);
|
||||||
el.assetList.innerHTML = "";
|
el.assetList.innerHTML = "";
|
||||||
if (!state.assets.length) {
|
if (!state.assets.length) {
|
||||||
@@ -360,7 +362,7 @@ async function loadAssets() {
|
|||||||
? "客户令牌无效或已过期,请重新输入"
|
? "客户令牌无效或已过期,请重新输入"
|
||||||
: error.message;
|
: error.message;
|
||||||
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||||||
el.videoAssetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
el.genericAssetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||||||
if (!state.config?.mockMode) {
|
if (!state.config?.mockMode) {
|
||||||
el.connectionState.textContent = "素材库连接失败";
|
el.connectionState.textContent = "素材库连接失败";
|
||||||
el.connectionState.className = "connection blocked";
|
el.connectionState.className = "connection blocked";
|
||||||
@@ -373,56 +375,79 @@ function assetItemType(item) {
|
|||||||
return String(item?.type || item?.asset_type || item?.AssetType || "image").toLowerCase();
|
return String(item?.type || item?.asset_type || item?.AssetType || "image").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVideoAssetLibrary() {
|
function isGenericAsset(item) {
|
||||||
el.videoAssetHistoryCount.textContent = String(state.videoAssets.length);
|
if (assetItemType(item) === "video") return true;
|
||||||
el.videoAssetList.innerHTML = "";
|
return /^通用图片\s*[·・]/.test(String(item?.purpose || item?.Name || ""));
|
||||||
if (!state.videoAssets.length) {
|
}
|
||||||
el.videoAssetList.innerHTML = '<div class="empty-small">暂无已就绪视频素材</div>';
|
|
||||||
|
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 = '<div class="empty-small">暂无已就绪通用素材</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const item of state.videoAssets) {
|
for (const item of state.genericAssets) {
|
||||||
|
const type = assetItemType(item);
|
||||||
const reference = normalizeAssetReference(item);
|
const reference = normalizeAssetReference(item);
|
||||||
const videoUrl = item.original_url || item.URL || "";
|
const sourceUrl = item.original_url || item.URL || "";
|
||||||
const name = item.purpose || item.Name || fileNameFromUrl(videoUrl) || "未命名视频素材";
|
const name = genericAssetName(item, sourceUrl);
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "video-asset-card";
|
card.className = "video-asset-card";
|
||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "video-asset-preview";
|
preview.className = "video-asset-preview";
|
||||||
if (videoUrl) {
|
if (sourceUrl && type === "video") {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.src = videoUrl;
|
video.src = sourceUrl;
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.preload = "metadata";
|
video.preload = "metadata";
|
||||||
preview.appendChild(video);
|
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");
|
const copy = document.createElement("div");
|
||||||
copy.className = "video-asset-copy";
|
copy.className = "video-asset-copy";
|
||||||
copy.innerHTML = `<strong>${escapeHtml(name)}</strong><span>${escapeHtml(reference)}</span>`;
|
copy.innerHTML = `<strong>${escapeHtml(name)}</strong><span>${type === "video" ? "视频" : "图片"} · ${escapeHtml(reference)}</span>`;
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "video-asset-actions";
|
actions.className = "video-asset-actions";
|
||||||
const useButton = document.createElement("button");
|
const useButton = document.createElement("button");
|
||||||
useButton.type = "button";
|
useButton.type = "button";
|
||||||
useButton.textContent = "用于当前片段";
|
useButton.textContent = "用于当前片段";
|
||||||
useButton.addEventListener("click", () => useVideoAssetInActiveShot(reference, name));
|
useButton.addEventListener("click", () => useGenericAssetInActiveShot(reference, name, type));
|
||||||
const copyButton = document.createElement("button");
|
const copyButton = document.createElement("button");
|
||||||
copyButton.type = "button";
|
copyButton.type = "button";
|
||||||
copyButton.textContent = "复制素材引用";
|
copyButton.textContent = "复制素材引用";
|
||||||
copyButton.addEventListener("click", async () => {
|
copyButton.addEventListener("click", async () => {
|
||||||
try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); }
|
try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); }
|
||||||
showToast("视频素材引用已复制");
|
showToast("素材引用已复制");
|
||||||
});
|
});
|
||||||
actions.append(useButton, copyButton);
|
actions.append(useButton, copyButton);
|
||||||
card.append(preview, copy, actions);
|
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 shot = activeShot();
|
||||||
const current = parseMediaLines(shot.referenceVideoUrls);
|
if (type === "video") {
|
||||||
if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true);
|
const current = parseMediaLines(shot.referenceVideoUrls);
|
||||||
shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n");
|
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();
|
saveDramaProject();
|
||||||
switchModule("production");
|
switchModule("production");
|
||||||
loadActiveShotIntoForm();
|
loadActiveShotIntoForm();
|
||||||
@@ -430,66 +455,76 @@ function useVideoAssetInActiveShot(reference, name) {
|
|||||||
showToast(`已将「${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();
|
event.preventDefault();
|
||||||
const url = el.videoAssetUrl.value.trim();
|
const type = el.genericAssetType.value;
|
||||||
const purpose = el.videoAssetPurpose.value.trim();
|
const url = el.genericAssetUrl.value.trim();
|
||||||
setVideoAssetStatus("processing", "审核中");
|
const purpose = el.genericAssetPurpose.value.trim();
|
||||||
el.videoAssetResult.classList.add("hidden");
|
setGenericAssetStatus("processing", "审核中");
|
||||||
el.videoAssetSubmit.disabled = true;
|
el.genericAssetResult.classList.add("hidden");
|
||||||
|
el.genericAssetSubmit.disabled = true;
|
||||||
try {
|
try {
|
||||||
let result = await api("/api/assets/upload", {
|
let result = await api("/api/assets/upload", {
|
||||||
method: "POST",
|
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") {
|
if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") {
|
||||||
const taskId = result.task_id || result.data?.task_id || result.id;
|
const taskId = result.task_id || result.data?.task_id || result.id;
|
||||||
if (!taskId) throw new Error("视频素材接口已受理,但没有返回任务 ID");
|
if (!taskId) throw new Error("素材接口已受理,但没有返回任务 ID");
|
||||||
savePendingVideoAsset(taskId, purpose);
|
savePendingGenericAsset(taskId, purpose, type);
|
||||||
result = await pollAssetTask(taskId, false);
|
result = await pollAssetTask(taskId, false);
|
||||||
}
|
}
|
||||||
await finishVideoAssetUpload(result);
|
await finishGenericAssetUpload(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
||||||
showToast(error.message, !error.pending);
|
showToast(error.message, !error.pending);
|
||||||
} finally {
|
} finally {
|
||||||
el.videoAssetSubmit.disabled = false;
|
el.genericAssetSubmit.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finishVideoAssetUpload(result) {
|
async function finishGenericAssetUpload(result) {
|
||||||
const items = getReviewItems(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];
|
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);
|
const reference = normalizeAssetReference(item);
|
||||||
if (!reference) throw new Error("审核通过,但未返回可用的 asset:// ID");
|
if (!reference) throw new Error("审核通过,但未返回可用的 asset:// ID");
|
||||||
clearPendingVideoAsset();
|
clearPendingGenericAsset();
|
||||||
setVideoAssetStatus("success", "已入库");
|
setGenericAssetStatus("success", "已入库");
|
||||||
el.videoAssetResultText.textContent = `素材引用:${reference}`;
|
el.genericAssetResultText.textContent = `素材引用:${reference}`;
|
||||||
el.videoAssetResult.classList.remove("hidden");
|
el.genericAssetResult.classList.remove("hidden");
|
||||||
el.videoAssetForm.reset();
|
el.genericAssetForm.reset();
|
||||||
|
updateGenericAssetForm();
|
||||||
await loadAssets();
|
await loadAssets();
|
||||||
showToast("视频素材已通过审核并入库");
|
showToast("通用素材已通过审核并入库");
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePendingVideoAsset(taskId, purpose) {
|
function savePendingGenericAsset(taskId, purpose, type) {
|
||||||
localStorage.setItem(PENDING_VIDEO_ASSET_KEY, JSON.stringify({ taskId, purpose, startedAt: Date.now() }));
|
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;
|
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;
|
if (!pending?.taskId) return;
|
||||||
setVideoAssetStatus("processing", "继续查询");
|
setGenericAssetStatus("processing", "继续查询");
|
||||||
try {
|
try {
|
||||||
const result = await pollAssetTask(pending.taskId, false);
|
const result = await pollAssetTask(pending.taskId, false);
|
||||||
await finishVideoAssetUpload(result);
|
await finishGenericAssetUpload(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
|
||||||
if (!error.pending) clearPendingVideoAsset();
|
if (!error.pending) clearPendingGenericAsset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,7 +619,7 @@ function bindCharacterToActiveRole(character) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchModule(module) {
|
function switchModule(module) {
|
||||||
if (!["library", "video-library", "production"].includes(module)) return;
|
if (!["library", "generic-library", "production"].includes(module)) return;
|
||||||
state.activeModule = module;
|
state.activeModule = module;
|
||||||
document.body.dataset.activeModule = module;
|
document.body.dataset.activeModule = module;
|
||||||
document.querySelectorAll('.module-tab').forEach((button) => {
|
document.querySelectorAll('.module-tab').forEach((button) => {
|
||||||
@@ -1310,7 +1345,7 @@ function createMediaInputRow(type, value = "") {
|
|||||||
row.className = "media-url-row";
|
row.className = "media-url-row";
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = type === "video" ? "text" : "url";
|
input.type = "text";
|
||||||
input.value = value;
|
input.value = value;
|
||||||
input.placeholder = placeholder;
|
input.placeholder = placeholder;
|
||||||
input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接");
|
input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接");
|
||||||
@@ -1722,7 +1757,7 @@ function updateProgress(value, title) {
|
|||||||
el.progressBar.style.width = `${safe}%`;
|
el.progressBar.style.width = `${safe}%`;
|
||||||
}
|
}
|
||||||
function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; }
|
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 setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; }
|
||||||
function setStep(active) {
|
function setStep(active) {
|
||||||
document.querySelectorAll(".step").forEach((step) => {
|
document.querySelectorAll(".step").forEach((step) => {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
<button class="module-tab active" type="button" data-module="library">
|
<button class="module-tab active" type="button" data-module="library">
|
||||||
<b>人物素材库</b><span>上传 1~3 张人物图片,审核后组合人物</span>
|
<b>人物素材库</b><span>上传 1~3 张人物图片,审核后组合人物</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="module-tab" type="button" data-module="video-library">
|
<button class="module-tab" type="button" data-module="generic-library">
|
||||||
<b>视频素材库</b><span>上传、审核并复用历史视频素材</span>
|
<b>通用素材库</b><span>上传、审核并复用图片与视频素材</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="module-tab" type="button" data-module="production">
|
<button class="module-tab" type="button" data-module="production">
|
||||||
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
||||||
@@ -94,19 +94,20 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-library-only video-library-summary">
|
<div class="generic-library-only video-library-summary">
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-title">
|
||||||
<span>视频素材</span>
|
<span>通用素材</span>
|
||||||
<button class="link-button" type="button" id="refreshVideoAssets">刷新</button>
|
<button class="link-button" type="button" id="refreshGenericAssets">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-library-count"><strong id="videoAssetHistoryCount">0</strong><span>条已就绪视频素材</span></div>
|
<div class="video-library-count"><strong id="genericAssetHistoryCount">0</strong><span>条已就绪图片/视频素材</span></div>
|
||||||
<div class="rule-box">
|
<div class="rule-box">
|
||||||
<strong>视频入库要求</strong>
|
<strong>通用素材入库要求</strong>
|
||||||
<ul>
|
<ul>
|
||||||
<li>提供公网可访问的视频 URL</li>
|
<li>提供公网可访问的图片或视频 URL</li>
|
||||||
<li>格式:MP4 / MOV</li>
|
<li>图片:JPG / PNG / WebP 等</li>
|
||||||
|
<li>视频:MP4 / MOV</li>
|
||||||
<li>入库审核通过后获得 asset:// 引用</li>
|
<li>入库审核通过后获得 asset:// 引用</li>
|
||||||
<li>可直接用于短剧片段的参考视频</li>
|
<li>可直接作为片段参考图片或参考视频</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,41 +160,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-header video-library-only">
|
<div class="panel-header generic-library-only">
|
||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">视频素材</span>
|
<span class="section-kicker">通用素材</span>
|
||||||
<h2>上传视频素材</h2>
|
<h2>上传图片或视频素材</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge neutral" id="videoAssetStatus">未提交</span>
|
<span class="status-badge neutral" id="genericAssetStatus">未提交</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="videoAssetForm" class="form-grid video-library-only">
|
<form id="genericAssetForm" class="form-grid generic-library-only">
|
||||||
<label class="field wide">
|
<label class="field">
|
||||||
<span>视频公网 URL</span>
|
<span>素材类型</span>
|
||||||
<input id="videoAssetUrl" type="url" required placeholder="https://example.com/reference.mp4" />
|
<select id="genericAssetType"><option value="image">图片素材</option><option value="video">视频素材</option></select>
|
||||||
<small>支持 MP4 / MOV,必须可通过公网直接访问。</small>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>素材名称</span>
|
<span>素材名称</span>
|
||||||
<input id="videoAssetPurpose" required placeholder="例如:电梯口上一片段 / 角色走路参考" />
|
<input id="genericAssetPurpose" required placeholder="例如:办公室场景 / 角色走路参考" />
|
||||||
</label>
|
</label>
|
||||||
<div class="field action-field">
|
<label class="field wide">
|
||||||
<span> </span>
|
<span>素材公网 URL</span>
|
||||||
<button class="primary" type="submit" id="videoAssetSubmit">提交视频素材审核</button>
|
<input id="genericAssetUrl" type="url" required placeholder="https://example.com/reference.jpg" />
|
||||||
|
<small id="genericAssetHint">支持 JPG / PNG / WebP 等图片格式,必须可通过公网直接访问。</small>
|
||||||
|
</label>
|
||||||
|
<div class="field action-field wide">
|
||||||
|
<button class="primary" type="submit" id="genericAssetSubmit">提交素材审核</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="result-card hidden video-library-only" id="videoAssetResult">
|
<div class="result-card hidden generic-library-only" id="genericAssetResult">
|
||||||
<div class="result-icon">✓</div>
|
<div class="result-icon">✓</div>
|
||||||
<div><strong>视频素材已通过审核</strong><p id="videoAssetResultText">可在下方历史素材中查看并用于短剧生产。</p></div>
|
<div><strong>通用素材已通过审核</strong><p id="genericAssetResultText">可在下方历史素材中查看并用于短剧生产。</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="video-asset-history video-library-only">
|
<section class="video-asset-history generic-library-only">
|
||||||
<div class="video-asset-history-head">
|
<div class="video-asset-history-head">
|
||||||
<div><span class="section-kicker">历史素材</span><h3>已就绪视频</h3></div>
|
<div><span class="section-kicker">历史素材</span><h3>已就绪图片与视频</h3></div>
|
||||||
<span>点击“用于当前片段”可直接带入短剧生产</span>
|
<span>点击“用于当前片段”可直接带入短剧生产</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-asset-grid" id="videoAssetList"><div class="empty-small">暂无已就绪视频素材</div></div>
|
<div class="video-asset-grid" id="genericAssetList"><div class="empty-small">暂无已就绪通用素材</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="storyboard production-only">
|
<div class="storyboard production-only">
|
||||||
@@ -236,7 +240,7 @@
|
|||||||
<button class="media-add-button" type="button" id="addReferenceVideoButton">+ 添加视频</button>
|
<button class="media-add-button" type="button" id="addReferenceVideoButton">+ 添加视频</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-url-list" id="referenceVideoInputs"></div>
|
<div class="media-url-list" id="referenceVideoInputs"></div>
|
||||||
<small>可填写公网 MP4/MOV,或从视频素材库选择 asset:// 引用;选择“引用上一片段”后系统会自动加入。</small>
|
<small>可填写公网 MP4/MOV,或从通用素材库选择视频;选择“引用上一片段”后系统会自动加入。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-quota wide" id="inputQuota">人物图片 0 张 · 其他图片 0 张 · 图片合计 0/9 · 视频 0 条</div>
|
<div class="input-quota wide" id="inputQuota">人物图片 0 张 · 其他图片 0 张 · 图片合计 0/9 · 视频 0 条</div>
|
||||||
@@ -347,6 +351,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast hidden" id="toast"></div>
|
<div class="toast hidden" id="toast"></div>
|
||||||
<script src="./app.js?v=32"></script>
|
<script src="./app.js?v=33"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 { 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); }
|
.module-tab.active b { color: var(--blue); }
|
||||||
body[data-active-module="library"] .production-only,
|
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"] .library-only,
|
||||||
body[data-active-module="production"] .video-library-only,
|
body[data-active-module="production"] .generic-library-only,
|
||||||
body[data-active-module="video-library"] .library-only,
|
body[data-active-module="generic-library"] .library-only,
|
||||||
body[data-active-module="video-library"] .production-only,
|
body[data-active-module="generic-library"] .production-only,
|
||||||
body[data-active-module="video-library"] .person-library-sidebar { display: none !important; }
|
body[data-active-module="generic-library"] .person-library-sidebar { display: none !important; }
|
||||||
body[data-active-module="library"] .asset-library-title { margin-top: 0; }
|
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); }
|
.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-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 { 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 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 { 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 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-copy span { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|||||||
19
server.mjs
19
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)) {
|
if (request.method === "POST" && ["/api/assets/review", "/api/assets/upload"].includes(url.pathname)) {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const assetType = String(body.type || "image").toLowerCase();
|
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("当前仅支持图片或视频素材");
|
if (!["image", "video"].includes(assetType)) throw new ClientError("当前仅支持图片或视频素材");
|
||||||
const assetInputs = (assetType === "video"
|
const assetInputs = (assetType === "video"
|
||||||
? [{ url: body.url, view: "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)
|
? body.images.map((item) => typeof item === "string" ? { url: item } : item)
|
||||||
: [{ url: body.url }])
|
: [{ url: body.url }])
|
||||||
.filter((item) => String(item?.url || "").trim());
|
.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 张");
|
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);
|
for (const item of assetInputs) validatePublicAssetUrl(item.url, assetType);
|
||||||
|
|
||||||
if (mockMode) {
|
if (mockMode) {
|
||||||
@@ -150,11 +151,14 @@ async function handleApi(request, response, url) {
|
|||||||
const items = assetInputs.map((input, index) => {
|
const items = assetInputs.map((input, index) => {
|
||||||
const id = `mat_demo_${Date.now()}_${index + 1}`;
|
const id = `mat_demo_${Date.now()}_${index + 1}`;
|
||||||
const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${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);
|
const rejected = /reject|fail/i.test(input.url);
|
||||||
if (!rejected) {
|
if (!rejected) {
|
||||||
mockAssets.unshift({
|
mockAssets.unshift({
|
||||||
asset_id: id,
|
asset_id: id,
|
||||||
purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`,
|
purpose: purposeLabel,
|
||||||
type: assetType,
|
type: assetType,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
original_url: input.url,
|
original_url: input.url,
|
||||||
@@ -187,13 +191,16 @@ async function handleApi(request, response, url) {
|
|||||||
const labels = { front: "正面", side: "侧面", back: "背面" };
|
const labels = { front: "正面", side: "侧面", back: "背面" };
|
||||||
const uploaded = await Promise.all(assetInputs.map(async (input, index) => {
|
const uploaded = await Promise.all(assetInputs.map(async (input, index) => {
|
||||||
const viewLabel = assetType === "video" ? (body.purpose || "视频素材") : labels[input.view] || `参考图${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 upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
|
const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: bearerHeaders(customerToken),
|
headers: bearerHeaders(customerToken),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: input.url,
|
url: input.url,
|
||||||
type: assetType,
|
type: assetType,
|
||||||
purpose: assetType === "video" ? viewLabel : `${body.name || "数字人形象"} · ${viewLabel}`,
|
purpose: purposeLabel,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) {
|
if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) {
|
||||||
@@ -559,8 +566,8 @@ function buildVideoPayload(body) {
|
|||||||
if (imageReferences.length > 9) {
|
if (imageReferences.length > 9) {
|
||||||
throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`);
|
throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`);
|
||||||
}
|
}
|
||||||
if (extraImageReferences.some((item) => !isPublicMediaUrl(item, "image"))) {
|
if (extraImageReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "image"))) {
|
||||||
throw new ClientError("其他参考图片必须是公网可访问的 http/https 图片 URL");
|
throw new ClientError("其他参考图片必须是 asset:// 素材引用或公网图片 URL");
|
||||||
}
|
}
|
||||||
if (videoReferences.length > 3 || videoReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "video"))) {
|
if (videoReferences.length > 3 || videoReferences.some((item) => !isAssetReference(item) && !isPublicMediaUrl(item, "video"))) {
|
||||||
throw new ClientError("参考视频最多 3 条,且必须是 asset:// 素材引用或公网 MP4/MOV URL");
|
throw new ClientError("参考视频最多 3 条,且必须是 asset:// 素材引用或公网 MP4/MOV URL");
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ test("生成时长提供 5 到 14 秒全部整数选项", () => {
|
|||||||
assert.deepEqual(values, [5, 6, 7, 8, 9, 10, 11, 12, 13, 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");
|
const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
|
||||||
assert.match(html, /data-module="video-library"/);
|
assert.match(html, /data-module="generic-library"/);
|
||||||
assert.match(html, /id="videoAssetForm"/);
|
assert.match(html, /id="genericAssetForm"/);
|
||||||
|
assert.match(html, /id="genericAssetType"/);
|
||||||
for (const id of ["assetFrontUrl", "assetSideUrl", "assetBackUrl"]) {
|
for (const id of ["assetFrontUrl", "assetSideUrl", "assetBackUrl"]) {
|
||||||
const input = html.match(new RegExp(`<input id="${id}"[^>]*>`))?.[0] || "";
|
const input = html.match(new RegExp(`<input id="${id}"[^>]*>`))?.[0] || "";
|
||||||
assert.ok(input, `缺少 ${id}`);
|
assert.ok(input, `缺少 ${id}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user