feat: add flexible character uploads and video asset library

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

View File

@@ -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 || `预期返回 13 张素材,实际返回 ${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) => {