diff --git a/.env.example b/.env.example index 5779eb5..7616101 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# 快快 AI Hub 统一 Key:素材库与 Seedance 2.0 均使用 Bearer 鉴权 -KK_API_KEY= +# 服务端统一使用的素材库 Token,不会下发到浏览器 +KK_ASSET_TOKEN= # 1 = 使用 Mock 数据评审流程;0 = 调用真实接口 DEMO_MOCK=1 diff --git a/README.md b/README.md index 37799c4..5a13bf4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## 已串联的流程 1. 输入数字人正面、侧面、背面三张公网图片 URL。 -2. 逐张调用 `POST /v1/assets/upload`,以统一 API Key 提交三张素材审核。 +2. 逐张调用 `POST /v1/assets/upload`,以服务端素材库 Token 提交三张素材审核。 3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。 4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。 5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。 @@ -37,7 +37,7 @@ ## 10–20 人试用的部署边界 -单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、访问鉴权/限流、进程守护和持久日志目录。所有用户共享同一个服务商 Key,需要另行确认上游 QPS、同时生成任务数和额度。`assetReviewTasks` 当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。 +单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。素材库 Token 由服务端统一配置;每位客户首次访问时输入自己的视频 Key,视频生成费用归属该 Key。`assetReviewTasks` 与视频任务 Key 映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。 页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。 @@ -45,7 +45,7 @@ 生产页支持文本、图片和视频三类输入。人物图片与场景、道具等其他参考图片共同占用单次最多 9 张图片额度;参考视频单独计数,当前 Demo 最多 3 条。最终请求按“文本 → 人物图片 → 其他参考图片 → 参考视频”的顺序写入 v3 `content` 数组,页面会同步生成图号和角色映射。 -所有第三方 token 都只放在本地服务端环境变量中,不进入浏览器,也不会写入 Git。 +素材库 Token 只放在服务端环境变量中,不进入浏览器,也不会写入 Git。客户视频 Key 保存在客户当前浏览器的 `localStorage`,并随视频请求提交给本 Demo 服务端;服务端只在内存中建立任务与 Key 的临时映射,不写入日志或 Git。清理网站数据或更换浏览器后需重新输入。 ## 本地运行 @@ -58,10 +58,10 @@ npm start 请优先通过这个地址访问,不要把 `public/index.html` 当成普通文档直接打开。页面现已兼容直接打开时的样式加载,但接口交互仍依赖本地服务。 -默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置: +默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置服务端素材库 Token;视频 Key 由客户在页面首次访问时输入: ```bash -KK_API_KEY=sk-统一接口Key +KK_ASSET_TOKEN=素材库Token DEMO_MOCK=0 ``` @@ -78,25 +78,26 @@ npm start 上线前请完成以下配置: -1. 在服务端填写 `KK_API_KEY`,并设置 `DEMO_MOCK=0`;不要把 `.env.local` 提交到 Git。 +1. 在服务端填写 `KK_ASSET_TOKEN`,并设置 `DEMO_MOCK=0`;不要把 `.env.local` 提交到 Git。视频 Key 不配置在服务器,由客户在页面输入。 2. 设置 `HOST=0.0.0.0` 和实际监听端口,由 Nginx/网关反向代理并提供 HTTPS。 3. 将 `AUDIT_LOG_DIR` 指向持久化磁盘,设置随机的 `AUDIT_HASH_SALT`,同时配置日志轮转与清理周期。 4. 使用 systemd、PM2 或容器编排守护 Node 进程,异常退出后自动重启。 -5. 在入口层增加客户访问鉴权、请求频率限制与并发生成限制;服务商 Key 仅保留在服务端。 -6. 发布后访问 `/api/config` 检查 `mockMode=false`、素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。 +5. 在入口层增加请求频率限制与并发生成限制;素材库 Token 仅保留在服务端。 +6. 发布后访问页面输入客户视频 Key,检查素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。 当前素材审核任务状态保存在单进程内存中,首次客户交付建议只运行一个应用实例。若需要多实例扩容,应先把任务状态迁移到 Redis 或数据库。 ## 当前还缺的真实联调材料 -- **快快 AI Hub 统一 Key**:形如 `sk-...`,同时用于新版素材库与 Seedance 2.0。 +- **快快素材库 Token**:由服务端统一配置,用于素材上传、审核与历史素材查询。 +- **客户视频 Key**:形如 `sk-...`,由客户首次打开页面时输入,用于视频提交和结果查询。 - **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。 - **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。 - **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。 ## 接口边界 -- 素材审核与视频生成统一使用 `https://ai-api.kkidc.com` 和同一个 Bearer Key。 +- 素材审核与视频生成统一请求 `https://ai-api.kkidc.com`,但分别使用服务端素材库 Token 与客户视频 Key。 - 新版素材库只在列表中返回 `ready` 素材;`pending` / `failed` 通过单素材接口查询。 - 视频生成应长期保存 `asset://mat_*`,不要依赖临时签名视频地址。 - 参考图/参考视频/首帧/首尾帧属于不同互斥调用方式;本 Demo 采用“多模态参考图”方式。 diff --git a/public/app.js b/public/app.js index 73f87d7..18fc3da 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,7 @@ const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup"; const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1"; const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1"; const DRAMA_PROJECT_KEY = "yingxiangli.dramaProject.v1"; +const VIDEO_KEY_STORAGE = "yingxiangli.videoKey.v1"; const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1"; const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1"; const VIEW_LABELS = ["正面", "侧面", "背面"]; @@ -53,6 +54,7 @@ const el = Object.fromEntries([ "referenceVideoInputs", "addExtraImageButton", "addReferenceVideoButton", "inputQuota", "addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount", "versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton", + "videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton", ].map((id) => [id, document.getElementById(id)])); const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack]; @@ -63,6 +65,7 @@ const auditInputTimers = new Map(); let auditFlushTimer = null; setupClientAudit(); +setupVideoKeyGate(); init(); document.querySelectorAll(".module-tab").forEach((button) => { @@ -76,12 +79,13 @@ async function init() { restoreCharacterLibrary(); restoreDramaProject(); renderConnection(); + if (!state.config.mockMode && !currentVideoKey()) showVideoKeyGate(false); await loadAssets(); loadActiveShotIntoForm(); renderScene(); renderStoryboard(); resumePendingReview(); - resumePendingVideo(); + if (state.config.mockMode || currentVideoKey()) resumePendingVideo(); } catch (error) { showToast(error.message, true); el.connectionState.textContent = "本地服务连接失败"; @@ -89,6 +93,42 @@ async function init() { } } +function setupVideoKeyGate() { + el.videoKeyForm.addEventListener("submit", (event) => { + event.preventDefault(); + const key = el.videoKeyInput.value.trim(); + if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(key)) { + el.videoKeyError.textContent = "请输入正确格式的生视频 Key(sk-...)"; + el.videoKeyError.classList.remove("hidden"); + return; + } + localStorage.setItem(VIDEO_KEY_STORAGE, key); + logClientOperation("video_key.saved", { keySuffix: key.slice(-4), keyLength: key.length }); + location.reload(); + }); + el.changeVideoKeyButton.addEventListener("click", () => showVideoKeyGate(true)); + el.cancelVideoKeyButton.addEventListener("click", hideVideoKeyGate); +} + +function currentVideoKey() { + try { return localStorage.getItem(VIDEO_KEY_STORAGE)?.trim() || ""; } + catch { return ""; } +} + +function showVideoKeyGate(canCancel = Boolean(currentVideoKey())) { + el.videoKeyInput.value = ""; + el.videoKeyError.textContent = ""; + el.videoKeyError.classList.add("hidden"); + el.cancelVideoKeyButton.classList.toggle("hidden", !canCancel); + el.videoKeyGate.classList.remove("hidden"); + requestAnimationFrame(() => el.videoKeyInput.focus()); +} + +function hideVideoKeyGate() { + if (!currentVideoKey() && !state.config?.mockMode) return; + el.videoKeyGate.classList.add("hidden"); +} + el.addRoleButton.addEventListener("click", () => { if (state.roles.length >= 3) return showToast("当前 Demo 最多支持三个场景角色,人物图片总数不超过 9 张", true); const role = createRole(`角色 ${ROLE_LETTERS[state.roles.length]}`); @@ -1403,6 +1443,9 @@ async function resumePendingVideo() { } function renderConnection() { + const videoKey = currentVideoKey(); + el.changeVideoKeyButton.classList.toggle("hidden", state.config.mockMode || !videoKey); + if (videoKey) el.changeVideoKeyButton.textContent = `更换视频 Key · ${videoKey.slice(-4)}`; if (state.config.mockMode) { el.connectionState.textContent = "Mock 模式 · 可评审完整流程"; el.connectionState.className = "connection mock"; @@ -1414,7 +1457,7 @@ function renderConnection() { el.reviewButton.textContent = "提交真实素材审核"; el.mockHint.classList.add("hidden"); } else if (state.config.assetReady) { - el.connectionState.textContent = "素材审核 Live · 视频 Key 待配置"; + el.connectionState.textContent = "请输入视频 Key"; el.connectionState.className = "connection live"; el.reviewButton.textContent = "提交真实素材审核"; el.mockHint.classList.add("hidden"); @@ -1663,11 +1706,15 @@ async function api(path, options = {}) { const startedAt = Date.now(); const method = options.method || "GET"; const shouldLog = shouldLogClientApi(path); + const videoKey = currentVideoKey(); + const videoHeaders = videoKey && (/^\/api\/videos(?:\/|$)/.test(path) || path === "/api/config") + ? { "X-Video-Key": videoKey } + : {}; if (shouldLog) logClientOperation("api.request", { method, path }); try { const response = await fetch(`${API_BASE}${path}`, { ...options, - headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...(options.headers || {}) }, + headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...videoHeaders, ...(options.headers || {}) }, }); const result = await response.json().catch(() => ({ error: "服务返回了无法解析的内容" })); if (shouldLog || !response.ok) { @@ -1683,6 +1730,10 @@ async function api(path, options = {}) { const error = new Error(extractApiError(result) || `请求失败(${response.status})`); error.payload = result; error.status = response.status; + if (response.status === 401 && /^\/api\/videos(?:\/|$)/.test(path)) { + localStorage.removeItem(VIDEO_KEY_STORAGE); + showVideoKeyGate(false); + } throw error; } return result; diff --git a/public/index.html b/public/index.html index 1db6cf8..c64ac10 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@