feat: expand video library into general asset library
This commit is contained in:
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")));
|
||||
|
||||
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 = `<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) {
|
||||
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 = '<div class="empty-small">暂无已就绪视频素材</div>';
|
||||
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 = '<div class="empty-small">暂无已就绪通用素材</div>';
|
||||
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 = `<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");
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user