diff --git a/README.md b/README.md index 2bcf914..5db8a45 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ 用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。 -页面拆分为两个一级模块: +页面拆分为三个一级模块: - **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。 +- **视频素材库**:上传并审核 MP4/MOV 公网视频,保留历史视频素材,并可直接引用到当前片段。 - **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。 在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。 @@ -13,13 +14,15 @@ ## 已串联的流程 -1. 输入数字人正面、侧面、背面三张公网图片 URL。 -2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交三张素材审核。 -3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。 +1. 输入数字人正面、侧面、背面中任意 1~3 张公网图片 URL。 +2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交人物素材审核;无需凑齐三张。 +3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至本次图片全部 `ready` 或出现 `failed`。 4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。 5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。 6. 用返回的 `id` 调用 `GET /sd/api/v3/contents/generations/tasks/{task_id}`,直至 `succeeded` 或 `failed`。 +视频素材库同样调用 `POST /v1/assets/upload`,但传入 `type: "video"`;审核完成后的 `asset://mat_*` 引用可作为当前片段的 `reference_video` 使用。 + 视频提交后页面每 2.5 秒自动查询一次,最长持续 30 分钟。只有上游明确返回 `failed` 才显示生成失败;30 分钟仍为 `running` 时会保留“仍在生成”状态,不把等待超时误判为失败。任务 ID 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。 已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。 @@ -39,7 +42,7 @@ 单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。每位客户首次访问时输入自己的统一令牌,素材与视频请求均使用该令牌,视频生成费用也归属该令牌。`assetReviewTasks` 与视频任务令牌映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。 -页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。 +页面会分别展示正面、侧面、背面的审核状态与失败原因;未上传的视角保持“未上传”,不会被误判为失败。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选择人物素材时不会解锁视频生成。 人物库会读取全部历史素材,并把名称形如“人物名 · 正面/侧面/背面”的素材自动组合成人物;也可以手动选择 1–9 张历史素材创建人物组合。场景支持 1–3 个角色,每个角色可绑定一组人物素材,并在刷新后恢复本场角色。 @@ -89,7 +92,7 @@ npm start ## 当前还缺的真实联调材料 - **客户统一令牌**:形如 `sk-...`,由客户首次打开页面时输入,同时用于素材上传/查询与视频提交/查询。 -- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。 +- **数字人人物图片的公网 URL**:正面、侧面、背面任选 1~3 张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。 - **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。 - **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。 diff --git a/public/app.js b/public/app.js index 241055c..ab0b9ef 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ window.addEventListener("error", (event) => recordRuntimeError(event.message || window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection"))); const PENDING_REVIEW_KEY = "yingxiangli.pendingAssetReview"; +const PENDING_VIDEO_ASSET_KEY = "yingxiangli.pendingVideoAssetReview.v1"; const PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1"; const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup"; const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1"; @@ -31,6 +32,7 @@ const state = { pollTimer: null, reviewStartedAt: 0, assets: [], + videoAssets: [], characters: [], characterDraftReferences: new Set(), activeModule: document.body.dataset.activeModule || "library", @@ -55,6 +57,8 @@ const el = Object.fromEntries([ "addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount", "versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton", "videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton", + "videoAssetForm", "videoAssetUrl", "videoAssetPurpose", "videoAssetSubmit", "videoAssetStatus", + "videoAssetResult", "videoAssetResultText", "videoAssetList", "videoAssetHistoryCount", "refreshVideoAssets", ].map((id) => [id, document.getElementById(id)])); const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack]; @@ -88,6 +92,7 @@ async function init() { renderScene(); renderStoryboard(); resumePendingReview(); + resumePendingVideoAsset(); if (state.config.mockMode || currentVideoKey()) resumePendingVideo(); } catch (error) { showToast(error.message, true); @@ -152,11 +157,17 @@ el.assetName.addEventListener("input", () => { el.assetForm.addEventListener("submit", async (event) => { event.preventDefault(); + const images = [ + { view: "front", url: el.assetFrontUrl.value.trim() }, + { view: "side", url: el.assetSideUrl.value.trim() }, + { view: "back", url: el.assetBackUrl.value.trim() }, + ].filter((item) => item.url); + if (!images.length) return showToast("正面、侧面、背面请至少填写一张", true); const role = activeRole(); role.name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`; setAssetStatus("processing", "审核中"); state.reviewStartedAt = Date.now(); - setAllReviewRows("processing", "审核中", "正在提交"); + preparePersonReviewRows(images); updateReviewElapsed(); el.reviewButton.disabled = true; @@ -165,11 +176,7 @@ el.assetForm.addEventListener("submit", async (event) => { method: "POST", body: JSON.stringify({ name: role.name, - images: [ - { view: "front", url: el.assetFrontUrl.value.trim() }, - { view: "side", url: el.assetSideUrl.value.trim() }, - { view: "back", url: el.assetBackUrl.value.trim() }, - ], + images, }), }); if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") { @@ -194,6 +201,8 @@ el.assetForm.addEventListener("submit", async (event) => { } }); +el.videoAssetForm.addEventListener("submit", submitVideoAsset); + el.videoForm.addEventListener("submit", async (event) => { event.preventDefault(); updateActiveShotFromForm(); @@ -268,6 +277,7 @@ el.videoForm.addEventListener("submit", async (event) => { }); el.refreshAssets.addEventListener("click", loadAssets); +el.refreshVideoAssets.addEventListener("click", loadAssets); el.createCharacterButton.addEventListener("click", openCharacterEditor); el.cancelCharacterButton.addEventListener("click", closeCharacterEditor); el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft); @@ -309,17 +319,19 @@ async function loadAssets() { const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length); if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break; } - state.assets = items; - ingestHistoricalCharacters(items); + state.assets = items.filter((item) => assetItemType(item) !== "video"); + state.videoAssets = items.filter((item) => assetItemType(item) === "video"); + ingestHistoricalCharacters(state.assets); renderCharacterLibrary(); renderAssetPicker(); - el.assetHistoryCount.textContent = String(items.length); + renderVideoAssetLibrary(); + el.assetHistoryCount.textContent = String(state.assets.length); el.assetList.innerHTML = ""; - if (!items.length) { - el.assetList.innerHTML = '
素材库暂无历史素材
'; + if (!state.assets.length) { + el.assetList.innerHTML = '
素材库暂无人物图片素材
'; 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 = `
${escapeHtml(message)}
`; + el.videoAssetList.innerHTML = `
${escapeHtml(message)}
`; 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 = '
暂无已就绪视频素材
'; + 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 = `${escapeHtml(name)}${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)); + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.textContent = "复制素材引用"; + copyButton.addEventListener("click", async () => { + try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); } + showToast("视频素材引用已复制"); + }); + actions.append(useButton, copyButton); + card.append(preview, copy, actions); + el.videoAssetList.appendChild(card); + } +} + +function useVideoAssetInActiveShot(reference, name) { + const shot = activeShot(); + const current = parseMediaLines(shot.referenceVideoUrls); + if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true); + shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n"); + saveDramaProject(); + switchModule("production"); + loadActiveShotIntoForm(); + renderStoryboard(); + showToast(`已将「${name}」用于当前片段`); +} + +async function submitVideoAsset(event) { + event.preventDefault(); + const url = el.videoAssetUrl.value.trim(); + const purpose = el.videoAssetPurpose.value.trim(); + setVideoAssetStatus("processing", "审核中"); + el.videoAssetResult.classList.add("hidden"); + el.videoAssetSubmit.disabled = true; + try { + let result = await api("/api/assets/upload", { + method: "POST", + body: JSON.stringify({ type: "video", url, purpose }), + }); + if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") { + const taskId = result.task_id || result.data?.task_id || result.id; + if (!taskId) throw new Error("视频素材接口已受理,但没有返回任务 ID"); + savePendingVideoAsset(taskId, purpose); + result = await pollAssetTask(taskId, false); + } + await finishVideoAssetUpload(result); + } catch (error) { + setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); + showToast(error.message, !error.pending); + } finally { + el.videoAssetSubmit.disabled = false; + } +} + +async function finishVideoAssetUpload(result) { + const items = getReviewItems(result); + if (items.length !== 1) throw new Error(result.message || "视频素材接口未返回有效结果"); + const item = items[0]; + if (Number(item.submit_review_status) !== 1) throw new Error(item.error_message || "视频素材未通过审核"); + const reference = normalizeAssetReference(item); + if (!reference) throw new Error("审核通过,但未返回可用的 asset:// ID"); + clearPendingVideoAsset(); + setVideoAssetStatus("success", "已入库"); + el.videoAssetResultText.textContent = `素材引用:${reference}`; + el.videoAssetResult.classList.remove("hidden"); + el.videoAssetForm.reset(); + await loadAssets(); + showToast("视频素材已通过审核并入库"); +} + +function savePendingVideoAsset(taskId, purpose) { + localStorage.setItem(PENDING_VIDEO_ASSET_KEY, JSON.stringify({ taskId, purpose, startedAt: Date.now() })); +} + +function clearPendingVideoAsset() { localStorage.removeItem(PENDING_VIDEO_ASSET_KEY); } + +async function resumePendingVideoAsset() { + let pending; + try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_ASSET_KEY) || "null"); } catch { pending = null; } + if (!pending?.taskId) return; + setVideoAssetStatus("processing", "继续查询"); + try { + const result = await pollAssetTask(pending.taskId, false); + await finishVideoAssetUpload(result); + } catch (error) { + setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); + if (!error.pending) clearPendingVideoAsset(); + } +} + function ingestHistoricalCharacters(items) { const groups = new Map(); for (const item of items) { @@ -439,6 +576,7 @@ function bindCharacterToActiveRole(character) { role.name = character.name; role.characterId = character.id; role.references = [...character.references]; + role.referenceViews = []; if (activeShot() && !activeShot().roleIds.includes(role.id)) activeShot().roleIds.push(role.id); renderScene(); if (state.activeModule === "library") switchModule("production"); @@ -446,7 +584,7 @@ function bindCharacterToActiveRole(character) { } function switchModule(module) { - if (!['library', 'production'].includes(module)) return; + if (!["library", "video-library", "production"].includes(module)) return; state.activeModule = module; document.body.dataset.activeModule = module; document.querySelectorAll('.module-tab').forEach((button) => { @@ -946,8 +1084,11 @@ function renderActiveRole() { el.activeRoleLabel.textContent = `角色 ${letter} · 人物素材`; el.assetName.value = role.name; reviewRows.forEach((_, index) => { - const reference = role.references[index]; - setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "待选择", reference || `下一张请选择${VIEW_LABELS[index]}图`); + const view = ["front", "side", "back"][index]; + const hasViewMap = Array.isArray(role.referenceViews) && role.referenceViews.length > 0; + const mappedIndex = hasViewMap ? role.referenceViews.indexOf(view) : index; + const reference = mappedIndex >= 0 ? role.references[mappedIndex] : ""; + setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "未上传", reference || `${VIEW_LABELS[index]}图为可选素材`); }); el.reviewElapsed.textContent = `角色 ${letter} · 已选 ${role.references.length} 张`; } @@ -1131,7 +1272,7 @@ function activeRole() { } function createRole(name) { - return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [] }; + return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [], referenceViews: [] }; } function saveSceneRoles() { @@ -1159,7 +1300,7 @@ function parseMediaLines(value) { function mediaInputConfig(type) { return type === "video" - ? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "https://example.com/reference.mp4" } + ? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "公网 MP4/MOV 或 asset://mat..." } : { list: el.extraImageInputs, button: el.addExtraImageButton, limit: 9, placeholder: "https://example.com/reference.jpg" }; } @@ -1169,7 +1310,7 @@ function createMediaInputRow(type, value = "") { row.className = "media-url-row"; const input = document.createElement("input"); - input.type = "url"; + input.type = type === "video" ? "text" : "url"; input.value = value; input.placeholder = placeholder; input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接"); @@ -1251,6 +1392,7 @@ function restoreSceneRoles() { references: Array.isArray(role.references) ? role.references.slice(0, 3).map(String).filter((value) => state.config.mockMode || /^asset:\/\/(?:mat_|asset-)/i.test(value)) : [], + referenceViews: Array.isArray(role.referenceViews) ? role.referenceViews.slice(0, 3).map(String) : [], })); state.activeRoleIndex = Math.min(Number(saved.activeRoleIndex) || 0, state.roles.length - 1); return; @@ -1268,9 +1410,9 @@ function restoreSceneRoles() { async function finishAssetReview(result, roleId, name) { const items = getReviewItems(result); renderReviewItems(items); - if (items.length !== 3) throw new Error(result.message || `预期返回 3 张素材,实际返回 ${items.length} 张`); + if (items.length < 1 || items.length > 3) throw new Error(result.message || `预期返回 1~3 张素材,实际返回 ${items.length} 张`); const failedItem = items.find((item) => Number(item.submit_review_status) !== 1); - if (failedItem) throw new Error(failedItem.error_message || "三视图中有素材未通过审核"); + if (failedItem) throw new Error(failedItem.error_message || "人物素材中有图片未通过审核"); const references = items.map(normalizeAssetReference); if (references.some((reference) => !reference)) throw new Error("审核通过,但有素材未返回可用的 asset:// ID"); clearPendingReview(); @@ -1278,20 +1420,21 @@ async function finishAssetReview(result, roleId, name) { state.activeRoleIndex = roleIndex; state.roles[roleIndex].name = name; state.roles[roleIndex].references = references; + state.roles[roleIndex].referenceViews = items.map((item) => String(item.view || "reference").toLowerCase()); upsertCharacter(name, references, "history", true); el.reviewElapsed.textContent = "审核完成"; await loadAssets(); renderScene(); - showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的三视图已全部通过审核`); + showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的 ${references.length} 张人物素材已全部通过审核`); } -async function pollAssetTask(taskId) { +async function pollAssetTask(taskId, renderItems = true) { for (let attempt = 0; attempt < 100; attempt += 1) { updateReviewElapsed(); await delay(3000); const result = await api(`/api/assets/tasks/${encodeURIComponent(taskId)}`); const items = getReviewItems(result); - if (items.length) renderReviewItems(items); + if (renderItems && items.length) renderReviewItems(items); const status = normalizeAssetTaskStatus(result); if (status === "completed" || status === "failed") return result; } @@ -1345,7 +1488,8 @@ function normalizeAssetTaskStatus(result) { } function renderReviewItems(items) { - items.slice(0, 3).forEach((item, index) => { + items.slice(0, 3).forEach((item, fallbackIndex) => { + const index = { front: 0, side: 1, back: 2 }[String(item.view || "").toLowerCase()] ?? fallbackIndex; const passed = Number(item.submit_review_status) === 1; const failed = Number(item.submit_review_status) === 0 || item.error_code || item.error_message; if (passed) setReviewRow(index, "success", "已通过", normalizeAssetReference(item)); @@ -1354,6 +1498,14 @@ function renderReviewItems(items) { }); } +function preparePersonReviewRows(images) { + reviewRows.forEach((_, index) => setReviewRow(index, "neutral", "未上传", "")); + images.forEach((item, fallbackIndex) => { + const index = { front: 0, side: 1, back: 2 }[item.view] ?? fallbackIndex; + setReviewRow(index, "processing", "审核中", "等待审核结果"); + }); +} + function setReviewRow(index, kind, status, detail = "") { const row = reviewRows[index]; if (!row) return; @@ -1478,7 +1630,7 @@ function renderConnection() { if (state.config.mockMode) { el.connectionState.textContent = "Mock 模式 · 可评审完整流程"; el.connectionState.className = "connection mock"; - el.reviewButton.textContent = "模拟提交三视图"; + el.reviewButton.textContent = "模拟提交人物素材"; el.mockHint.classList.remove("hidden"); } else if (state.config.assetReady && state.config.videoReady) { el.connectionState.textContent = "真实接口已连接"; @@ -1567,6 +1719,7 @@ function updateProgress(value, title) { el.progressBar.style.width = `${safe}%`; } function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; } +function setVideoAssetStatus(kind, text) { el.videoAssetStatus.className = `status-badge ${kind}`; el.videoAssetStatus.textContent = text; } function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; } function setStep(active) { document.querySelectorAll(".step").forEach((step) => { diff --git a/public/index.html b/public/index.html index ebef5c2..0f9149f 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ 营响力|AI 短剧数字人 Demo - +
@@ -21,7 +21,10 @@