diff --git a/README.md b/README.md index e848e85..c1e0bbe 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -# 营响力 AI 短剧数字人 Demo +# 营响力 AI 视频与短剧生产 Demo 用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。 -页面拆分为三个一级模块: +页面拆分为四个一级模块: - **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。 - **通用素材库**:上传并审核公网图片或 MP4/MOV 视频,保留历史素材,并可作为当前片段的其他参考图片或参考视频。 +- **通用视频生成**:不创建角色和短剧片段,直接输入提示词,选择通用素材或补充公网图片/视频生成单条视频;也支持纯文本生成。 - **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。 在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。 @@ -18,7 +19,7 @@ 2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交人物素材审核;无需凑齐三张。 3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至本次图片全部 `ready` 或出现 `failed`。 4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。 -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`。 通用素材库同样调用 `POST /v1/assets/upload`:图片传入 `type: "image"`,视频传入 `type: "video"`。审核完成后的 `asset://mat_*` 引用可分别作为当前片段的其他参考图片或 `reference_video` 使用。通用图片用明确的素材用途标记与人物图片分开,避免进入人物组合区。 @@ -27,7 +28,7 @@ 已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。 -短剧、片段、候选版本、任务 ID 和视频地址保存在当前域名的浏览器 `localStorage` 中。普通刷新不会丢失;清理网站数据、使用无痕模式、更换浏览器/设备/域名会丢失本地项目记录。在没有数据库和对象存储的当前版本中,仍建议用户及时下载成品。 +短剧、片段、候选版本、通用视频生成记录、任务 ID 和视频地址保存在当前域名的浏览器 `localStorage` 中。普通刷新不会丢失;清理网站数据、使用无痕模式、更换浏览器/设备/域名会丢失本地记录。在没有数据库和对象存储的当前版本中,仍建议用户及时下载成品。 ## 不可见审计日志 diff --git a/public/app.js b/public/app.js index 931360f..ee65766 100644 --- a/public/app.js +++ b/public/app.js @@ -15,6 +15,8 @@ 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 GENERAL_VIDEO_STATE_KEY = "yingxiangli.generalVideo.v1"; +const PENDING_GENERAL_VIDEO_KEY = "yingxiangli.pendingGeneralVideo.v1"; const VIDEO_KEY_STORAGE = "yingxiangli.videoKey.v1"; const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1"; const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1"; @@ -41,6 +43,9 @@ const state = { activeShotId: null, activeGenerationShotId: null, activeGenerationVersionId: null, + generalSelectedReferences: new Set(), + generalResults: [], + generalPollTimer: null, }; const el = Object.fromEntries([ @@ -59,6 +64,10 @@ const el = Object.fromEntries([ "videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton", "genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus", "genericAssetHint", "genericAssetResult", "genericAssetResultText", "genericAssetList", "genericAssetHistoryCount", "refreshGenericAssets", + "generalVideoForm", "generalVideoStatus", "generalPrompt", "generalAssetPicker", "refreshGeneralAssets", + "generalImageInputs", "generalVideoInputs", "addGeneralImageButton", "addGeneralVideoButton", "generalInputQuota", + "generalModel", "generalRatio", "generalDuration", "generalResolution", "generalGenerateAudio", "generalWatermark", + "generalGenerateButton", "generalResultCount", "generalResultList", ].map((id) => [id, document.getElementById(id)])); const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack]; @@ -83,6 +92,7 @@ async function init() { restoreSceneRoles(); restoreCharacterLibrary(); restoreDramaProject(); + restoreGeneralVideoState(); renderConnection(); if (!state.config.mockMode && !currentVideoKey()) { showVideoKeyGate(false); @@ -95,6 +105,7 @@ async function init() { resumePendingReview(); resumePendingGenericAsset(); if (state.config.mockMode || currentVideoKey()) resumePendingVideo(); + if (state.config.mockMode || currentVideoKey()) resumePendingGeneralVideo(); } catch (error) { showToast(error.message, true); el.connectionState.textContent = "本地服务连接失败"; @@ -204,6 +215,16 @@ el.assetForm.addEventListener("submit", async (event) => { el.genericAssetForm.addEventListener("submit", submitGenericAsset); el.genericAssetType.addEventListener("change", updateGenericAssetForm); +el.generalVideoForm.addEventListener("submit", submitGeneralVideo); +el.refreshGeneralAssets.addEventListener("click", loadAssets); +el.addGeneralImageButton.addEventListener("click", () => addGeneralMediaInput("image")); +el.addGeneralVideoButton.addEventListener("click", () => addGeneralMediaInput("video")); +[el.generalImageInputs, el.generalVideoInputs].forEach((list) => list.addEventListener("click", (event) => { + const button = event.target.closest("[data-remove-general-media]"); + if (button) removeGeneralMediaInput(button); +})); +[el.generalPrompt, el.generalImageInputs, el.generalVideoInputs].forEach((input) => input.addEventListener("input", saveGeneralVideoState)); +[el.generalModel, el.generalRatio, el.generalDuration, el.generalResolution, el.generalGenerateAudio, el.generalWatermark].forEach((input) => input.addEventListener("change", saveGeneralVideoState)); el.videoForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -327,6 +348,7 @@ async function loadAssets() { renderCharacterLibrary(); renderAssetPicker(); renderGenericAssetLibrary(); + renderGeneralAssetPicker(); el.assetHistoryCount.textContent = String(state.assets.length); el.assetList.innerHTML = ""; if (!state.assets.length) { @@ -446,6 +468,379 @@ function renderGenericAssetLibrary() { } } +function restoreGeneralVideoState() { + let saved; + try { saved = JSON.parse(localStorage.getItem(GENERAL_VIDEO_STATE_KEY) || "null"); } catch { saved = null; } + state.generalSelectedReferences = new Set(Array.isArray(saved?.selectedReferences) ? saved.selectedReferences.map(String) : []); + state.generalResults = Array.isArray(saved?.results) ? saved.results.slice(0, 20) : []; + el.generalPrompt.value = String(saved?.prompt || ""); + el.generalModel.value = saved?.model || "doubao-seedance-2-0-260128"; + el.generalRatio.value = saved?.ratio || "9:16"; + el.generalDuration.value = String(Math.min(14, Math.max(5, Number(saved?.duration) || 5))); + el.generalResolution.value = saved?.resolution === "480p" ? "480p" : "720p"; + el.generalGenerateAudio.checked = saved?.generateAudio !== false; + el.generalWatermark.checked = Boolean(saved?.watermark); + renderGeneralMediaInputs("image", saved?.imageUrls || ""); + renderGeneralMediaInputs("video", saved?.videoUrls || ""); + renderGeneralResults(); + updateGeneralQuota(); +} + +function saveGeneralVideoState() { + localStorage.setItem(GENERAL_VIDEO_STATE_KEY, JSON.stringify({ + prompt: el.generalPrompt.value, + selectedReferences: [...state.generalSelectedReferences], + imageUrls: collectGeneralMediaUrls("image").join("\n"), + videoUrls: collectGeneralMediaUrls("video").join("\n"), + model: el.generalModel.value, + ratio: el.generalRatio.value, + duration: Number(el.generalDuration.value), + resolution: el.generalResolution.value, + generateAudio: el.generalGenerateAudio.checked, + watermark: el.generalWatermark.checked, + results: state.generalResults.slice(0, 20), + })); + updateGeneralQuota(); +} + +function renderGeneralAssetPicker() { + if (!el.generalAssetPicker) return; + const available = new Set(state.genericAssets.map(normalizeAssetReference)); + for (const reference of [...state.generalSelectedReferences]) { + if (!available.has(reference)) state.generalSelectedReferences.delete(reference); + } + el.generalAssetPicker.innerHTML = ""; + if (!state.genericAssets.length) { + el.generalAssetPicker.innerHTML = '
通用素材库暂无可用图片或视频
'; + updateGeneralQuota(); + return; + } + for (const item of state.genericAssets) { + const type = assetItemType(item); + const reference = normalizeAssetReference(item); + const sourceUrl = item.original_url || item.URL || ""; + const name = genericAssetName(item, sourceUrl); + const button = document.createElement("button"); + button.type = "button"; + button.className = `general-asset-choice${state.generalSelectedReferences.has(reference) ? " selected" : ""}`; + const preview = document.createElement("span"); + preview.className = "general-asset-choice-preview"; + if (sourceUrl && type === "image") { + const image = document.createElement("img"); + image.src = sourceUrl; + image.alt = name; + image.loading = "lazy"; + preview.appendChild(image); + } else if (sourceUrl && type === "video") { + const video = document.createElement("video"); + video.src = sourceUrl; + video.muted = true; + video.playsInline = true; + video.preload = "metadata"; + preview.appendChild(video); + } else preview.textContent = type === "video" ? "视频" : "图片"; + const copy = document.createElement("span"); + copy.className = "general-asset-choice-copy"; + copy.innerHTML = `${escapeHtml(name)}${type === "video" ? "视频" : "图片"}`; + button.append(preview, copy); + button.addEventListener("click", () => toggleGeneralAsset(reference, type)); + el.generalAssetPicker.appendChild(button); + } + updateGeneralQuota(); +} + +function toggleGeneralAsset(reference, type) { + if (state.generalSelectedReferences.has(reference)) state.generalSelectedReferences.delete(reference); + else { + const quota = generalMediaQuota(reference, type); + if (!quota.valid) return showToast(quota.message, true); + state.generalSelectedReferences.add(reference); + } + saveGeneralVideoState(); + renderGeneralAssetPicker(); +} + +function generalMediaInputConfig(type) { + return type === "video" + ? { list: el.generalVideoInputs, button: el.addGeneralVideoButton, limit: 3, placeholder: "公网 MP4/MOV 或 asset:// 素材引用" } + : { list: el.generalImageInputs, button: el.addGeneralImageButton, limit: 9, placeholder: "公网图片 URL 或 asset:// 素材引用" }; +} + +function createGeneralMediaInput(type, value = "") { + const { placeholder } = generalMediaInputConfig(type); + const row = document.createElement("div"); + row.className = "media-url-row"; + const input = document.createElement("input"); + input.type = "text"; + input.value = value; + input.placeholder = placeholder; + input.setAttribute("aria-label", type === "video" ? "通用生成参考视频" : "通用生成参考图片"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "media-remove-button"; + remove.dataset.removeGeneralMedia = type; + remove.setAttribute("aria-label", "删除该链接"); + remove.textContent = "×"; + row.append(input, remove); + return row; +} + +function renderGeneralMediaInputs(type, value) { + const { list } = generalMediaInputConfig(type); + const values = parseMediaLines(value); + list.replaceChildren(...(values.length ? values : [""]).map((item) => createGeneralMediaInput(type, item))); + refreshGeneralMediaButton(type); +} + +function collectGeneralMediaUrls(type) { + const { list } = generalMediaInputConfig(type); + return [...new Set([...list.querySelectorAll("input")].map((input) => input.value.trim()).filter(Boolean))]; +} + +function addGeneralMediaInput(type) { + const { list, limit } = generalMediaInputConfig(type); + if (list.children.length >= limit) return; + const row = createGeneralMediaInput(type); + list.appendChild(row); + row.querySelector("input").focus(); + refreshGeneralMediaButton(type); +} + +function removeGeneralMediaInput(button) { + const type = button.dataset.removeGeneralMedia; + const { list } = generalMediaInputConfig(type); + if (list.children.length === 1) list.querySelector("input").value = ""; + else button.closest(".media-url-row").remove(); + refreshGeneralMediaButton(type); + saveGeneralVideoState(); +} + +function refreshGeneralMediaButton(type) { + const { list, button, limit } = generalMediaInputConfig(type); + button.disabled = list.children.length >= limit; +} + +function selectedGeneralLibraryMedia() { + const selected = state.genericAssets.filter((item) => state.generalSelectedReferences.has(normalizeAssetReference(item))); + return { + images: selected.filter((item) => assetItemType(item) === "image").map(normalizeAssetReference), + videos: selected.filter((item) => assetItemType(item) === "video").map(normalizeAssetReference), + }; +} + +function generalMediaQuota(pendingReference = "", pendingType = "") { + const selected = selectedGeneralLibraryMedia(); + const imageReferences = new Set([...selected.images, ...collectGeneralMediaUrls("image")]); + const videoReferences = new Set([...selected.videos, ...collectGeneralMediaUrls("video")]); + if (pendingReference && !state.generalSelectedReferences.has(pendingReference)) { + if (pendingType === "video") videoReferences.add(pendingReference); + else imageReferences.add(pendingReference); + } + const imageCount = imageReferences.size; + const videoCount = videoReferences.size; + if (imageCount > 9) return { valid: false, imageCount, videoCount, message: `参考图片 ${imageCount}/9,已超出上限` }; + if (videoCount > 3) return { valid: false, imageCount, videoCount, message: `参考视频 ${videoCount}/3,已超出上限` }; + return { valid: true, imageCount, videoCount, message: "可提交" }; +} + +function updateGeneralQuota() { + if (!el.generalInputQuota) return; + const quota = generalMediaQuota(); + el.generalInputQuota.textContent = `参考图片 ${quota.imageCount}/9 · 参考视频 ${quota.videoCount}/3`; + el.generalInputQuota.className = `input-quota wide ${quota.valid ? "valid" : "invalid"}`; +} + +async function submitGeneralVideo(event) { + event.preventDefault(); + const prompt = el.generalPrompt.value.trim(); + if (!prompt) return showToast("请填写视频提示词", true); + const quota = generalMediaQuota(); + if (!quota.valid) return showToast(quota.message, true); + const selected = selectedGeneralLibraryMedia(); + const images = [...new Set([...selected.images, ...collectGeneralMediaUrls("image")])]; + const videos = [...new Set([...selected.videos, ...collectGeneralMediaUrls("video")])]; + const result = { + id: `general-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + status: "running", + taskId: "", + videoUrl: "", + error: "", + ratio: el.generalRatio.value, + createdAt: Date.now(), + lastCheckedAt: null, + progress: 5, + }; + state.generalResults.unshift(result); + state.generalResults = state.generalResults.slice(0, 20); + el.generalGenerateButton.disabled = true; + setGeneralVideoStatus("processing", "生成中"); + saveGeneralVideoState(); + renderGeneralResults(); + try { + const response = await api("/api/videos", { + method: "POST", + body: JSON.stringify({ + assetReferences: [], + extraImageReferences: images, + videoReferences: videos, + prompt, + model: el.generalModel.value, + ratio: el.generalRatio.value, + duration: Number(el.generalDuration.value), + resolution: el.generalResolution.value, + generateAudio: el.generalGenerateAudio.checked, + watermark: el.generalWatermark.checked, + }), + }); + const taskId = response.task_id || response.id || response.data?.task_id || response.data?.id; + if (!taskId) throw new Error("视频接口未返回任务 ID"); + result.taskId = taskId; + result.mock = Boolean(response.mock); + localStorage.setItem(PENDING_GENERAL_VIDEO_KEY, JSON.stringify({ taskId, resultId: result.id, startedAt: result.createdAt })); + saveGeneralVideoState(); + renderGeneralResults(); + await pollGeneralVideoTask(taskId, result.id); + } catch (error) { + const message = contextualizeGenerationError(error.message); + if (error.pending) { + setGeneralVideoStatus("processing", "仍在生成"); + showToast(message); + } else { + result.status = "failed"; + result.error = message; + localStorage.removeItem(PENDING_GENERAL_VIDEO_KEY); + setGeneralVideoStatus("error", "生成失败"); + showToast(message, true); + } + } finally { + saveGeneralVideoState(); + renderGeneralResults(); + el.generalGenerateButton.disabled = state.generalResults.some((item) => item.status === "running"); + } +} + +async function pollGeneralVideoTask(taskId, resultId) { + const startedAt = Date.now(); + while (Date.now() - startedAt < VIDEO_POLL_MAX_MS) { + await delay(VIDEO_POLL_INTERVAL_MS); + const response = await api(`/api/videos/${encodeURIComponent(taskId)}`); + const status = response._normalized?.status || normalizeVideoStatus(response.status || response.data?.status); + const result = state.generalResults.find((item) => item.id === resultId); + if (!result) return; + result.taskId = taskId; + result.lastCheckedAt = Date.now(); + result.progress = Math.max(10, Number(response.progress ?? response.data?.progress ?? (status === "completed" ? 100 : 20))); + result.mock = result.mock || Boolean(response.mock); + saveGeneralVideoState(); + renderGeneralResults(); + if (status === "completed") { + result.status = "completed"; + result.progress = 100; + result.videoUrl = response._normalized?.videoUrl || response.video_url || response.output?.video_url || response.data?.video_url || ""; + localStorage.removeItem(PENDING_GENERAL_VIDEO_KEY); + setGeneralVideoStatus("success", "生成完成"); + saveGeneralVideoState(); + renderGeneralResults(); + showToast("通用视频已生成"); + return; + } + if (status === "failed") throw new Error(response.error?.message || response.message || "视频生成失败"); + } + const error = new Error("视频仍在服务商侧生成,任务已保存;稍后重新打开页面会继续查询"); + error.pending = true; + throw error; +} + +async function resumePendingGeneralVideo() { + let pending; + try { pending = JSON.parse(localStorage.getItem(PENDING_GENERAL_VIDEO_KEY) || "null"); } catch { pending = null; } + if (!pending?.taskId) return; + switchModule("general-video"); + let result = state.generalResults.find((item) => item.id === pending.resultId); + if (!result) { + result = { id: pending.resultId || `general-${Date.now()}`, status: "running", taskId: pending.taskId, videoUrl: "", error: "", ratio: "9:16", createdAt: pending.startedAt || Date.now(), lastCheckedAt: null, progress: 20 }; + state.generalResults.unshift(result); + } + result.status = "running"; + el.generalGenerateButton.disabled = true; + setGeneralVideoStatus("processing", "继续查询"); + renderGeneralResults(); + try { + await pollGeneralVideoTask(pending.taskId, result.id); + } catch (error) { + if (error.pending) { + setGeneralVideoStatus("processing", "仍在生成"); + showToast(error.message); + } else { + result.status = "failed"; + result.error = contextualizeGenerationError(error.message); + localStorage.removeItem(PENDING_GENERAL_VIDEO_KEY); + setGeneralVideoStatus("error", "生成失败"); + showToast(result.error, true); + } + } finally { + saveGeneralVideoState(); + renderGeneralResults(); + el.generalGenerateButton.disabled = state.generalResults.some((item) => item.status === "running"); + } +} + +function renderGeneralResults() { + if (!el.generalResultList) return; + el.generalResultCount.textContent = state.generalResults.length ? `${state.generalResults.length} 条记录` : "暂无记录"; + el.generalResultList.innerHTML = ""; + if (!state.generalResults.length) { + el.generalResultList.innerHTML = '
提交任务后,生成状态和视频会出现在这里
'; + return; + } + state.generalResults.forEach((result, index) => { + const card = document.createElement("div"); + card.className = "version-card"; + const preview = document.createElement("div"); + preview.className = "version-preview"; + setVersionPreviewRatio(preview, result.ratio || "16:9"); + if (result.videoUrl) { + const video = document.createElement("video"); + video.src = result.videoUrl; + video.controls = true; + video.playsInline = true; + video.preload = "metadata"; + video.addEventListener("loadedmetadata", () => { + if (video.videoWidth && video.videoHeight) setVersionPreviewRatio(preview, `${video.videoWidth}:${video.videoHeight}`); + }, { once: true }); + preview.appendChild(video); + } else preview.textContent = result.status === "running" ? `${result.progress || 20}% · 生成中` : result.status === "completed" ? "流程已完成" : "生成失败"; + const copy = document.createElement("div"); + copy.className = "version-copy"; + const statusText = result.status === "completed" ? "已完成" : result.status === "running" ? `生成中 · 已等待 ${formatElapsed(result.createdAt)}` : "失败"; + copy.innerHTML = `视频 ${state.generalResults.length - index} · ${statusText}${escapeHtml(result.taskId || result.error || "正在获取任务 ID")}`; + const actions = document.createElement("div"); + actions.className = "version-actions"; + if (result.status === "completed" && result.taskId && !result.mock) { + const download = document.createElement("a"); + download.className = "video-action-link"; + download.href = result.videoUrl || videoProxyUrl(result.taskId); + download.textContent = "下载视频"; + download.setAttribute("download", ""); + download.target = "_blank"; + download.rel = "noopener"; + const copyLink = document.createElement("button"); + copyLink.type = "button"; + copyLink.className = "video-action-link"; + copyLink.textContent = "复制视频链接"; + copyLink.addEventListener("click", () => copyGeneratedVideoLink(result.taskId)); + actions.append(download, copyLink); + } + card.append(preview, copy, actions); + el.generalResultList.appendChild(card); + }); +} + +function setGeneralVideoStatus(kind, text) { + el.generalVideoStatus.className = `status-badge ${kind}`; + el.generalVideoStatus.textContent = text; +} + function useGenericAssetInActiveShot(reference, name, type) { const shot = activeShot(); if (type === "video") { @@ -629,7 +1024,7 @@ function bindCharacterToActiveRole(character) { } function switchModule(module) { - if (!["library", "generic-library", "production"].includes(module)) return; + if (!["library", "generic-library", "general-video", "production"].includes(module)) return; state.activeModule = module; document.body.dataset.activeModule = module; document.querySelectorAll('.module-tab').forEach((button) => { diff --git a/public/index.html b/public/index.html index 900ef47..7bf12a1 100644 --- a/public/index.html +++ b/public/index.html @@ -3,14 +3,14 @@ - 营响力|AI 短剧数字人 Demo - + 营响力|AI 视频与短剧生产 +
营响力 · 真实客户验证 -

AI 短剧数字人生产

+

AI 视频与短剧生产

@@ -26,6 +26,9 @@ + @@ -200,6 +203,56 @@
暂无已就绪通用素材
+
+
+ 通用视频 +

生成一条视频

+
+ 可提交 +
+ +
+ + +
+
+
从通用素材库选择点击素材即可选中或取消
+ +
+
暂无通用素材
+
+ +
+
+
其他参考图片
+
+ 可填写公网图片 URL 或 asset:// 素材引用,最多 9 张。 +
+
+
参考视频
+
+ 可填写公网 MP4/MOV 或 asset:// 素材引用,最多 3 条。 +
+
+
参考图片 0/9 · 参考视频 0/3
+ + + + + + + +
+
+ +
+
生成记录

本浏览器生成的视频

暂无记录
+
提交任务后,生成状态和视频会出现在这里
+
+
- + diff --git a/public/styles.css b/public/styles.css index fe6a40f..84513dc 100644 --- a/public/styles.css +++ b/public/styles.css @@ -53,7 +53,7 @@ button { cursor: pointer; } main { max-width: 1480px; margin: 0 auto; padding: 24px; } .panel, .sidebar { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; } -.module-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; } +.module-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 18px 0; } .module-tab { min-height: 72px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 14px 18px; display: grid; gap: 4px; text-align: left; color: var(--muted); } .module-tab b { color: var(--ink); font-size: 16px; } .module-tab span { font-size: 12px; } @@ -61,11 +61,19 @@ main { max-width: 1480px; margin: 0 auto; padding: 24px; } .module-tab.active b { color: var(--blue); } body[data-active-module="library"] .production-only, body[data-active-module="library"] .generic-library-only, +body[data-active-module="library"] .general-video-only, body[data-active-module="production"] .library-only, body[data-active-module="production"] .generic-library-only, +body[data-active-module="production"] .general-video-only, body[data-active-module="generic-library"] .library-only, body[data-active-module="generic-library"] .production-only, -body[data-active-module="generic-library"] .person-library-sidebar { display: none !important; } +body[data-active-module="generic-library"] .general-video-only, +body[data-active-module="generic-library"] .person-library-sidebar, +body[data-active-module="general-video"] .library-only, +body[data-active-module="general-video"] .production-only, +body[data-active-module="general-video"] .generic-library-only, +body[data-active-module="general-video"] .sidebar { display: none !important; } +body[data-active-module="general-video"] .workspace { grid-template-columns: minmax(0, 1fr); } 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); } @@ -161,6 +169,19 @@ body[data-active-module="library"] .asset-library-title { margin-top: 0; } .video-asset-copy span { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } .video-asset-actions { display: flex; gap: 8px; padding: 0 12px 12px; } .video-asset-actions button { flex: 1; border: 1px solid #cbd4e5; border-radius: 8px; background: white; padding: 7px 9px; color: var(--blue); font-size: 11px; } +.general-asset-picker { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; } +.general-asset-picker .media-field-head > div { display: grid; gap: 3px; } +.general-asset-picker .media-field-head small { color: var(--muted); font-size: 11px; font-weight: 400; } +.general-asset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 12px; } +.general-asset-grid > .empty-small { grid-column: 1 / -1; } +.general-asset-choice { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: white; text-align: left; } +.general-asset-choice.selected { border-color: var(--blue); background: var(--blue-soft); box-shadow: 0 0 0 2px rgba(41,87,232,.08); } +.general-asset-choice-preview { aspect-ratio: 16 / 9; display: grid; place-items: center; overflow: hidden; background: #111827; color: white; font-size: 11px; } +.general-asset-choice-preview img, .general-asset-choice-preview video { width: 100%; height: 100%; object-fit: contain; } +.general-asset-choice-copy { display: grid; gap: 3px; padding: 8px 9px; } +.general-asset-choice-copy strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.general-asset-choice-copy span { color: var(--muted); font-size: 10px; } +.general-video-results { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); } .rule-box { margin-top: 18px; padding: 14px; border-radius: 12px; background: #f7f9fd; font-size: 12px; } .rule-box ul { padding-left: 18px; margin: 8px 0 0; color: var(--muted); line-height: 1.7; } @@ -306,5 +327,6 @@ hr { border: 0; border-top: 1px solid var(--line); margin: 26px 0 0; } .view-grid { grid-template-columns: 1fr; } .multimodal-grid { grid-template-columns: 1fr; } .video-asset-grid { grid-template-columns: 1fr; } + .general-asset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .field.wide { grid-column: auto; } } diff --git a/server.mjs b/server.mjs index 740cddf..f8415fb 100644 --- a/server.mjs +++ b/server.mjs @@ -560,8 +560,8 @@ function buildVideoPayload(body) { const extraImageReferences = Array.isArray(body.extraImageReferences) ? body.extraImageReferences : []; const videoReferences = Array.isArray(body.videoReferences) ? body.videoReferences : []; const imageReferences = [...assetReferences, ...extraImageReferences]; - if (assetReferences.length < 1 || assetReferences.some((item) => !/^asset:\/\/(?:mat_|asset-)/i.test(item))) { - throw new ClientError("请先选择审核通过的人物素材(asset://...)"); + if (assetReferences.some((item) => !/^asset:\/\/(?:mat_|asset-)/i.test(item))) { + throw new ClientError("人物素材必须是审核通过的 asset:// 引用"); } if (imageReferences.length > 9) { throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`); diff --git a/tests/normalize.test.mjs b/tests/normalize.test.mjs index 7e8484a..83710d6 100644 --- a/tests/normalize.test.mjs +++ b/tests/normalize.test.mjs @@ -44,3 +44,13 @@ test("人物素材支持任选 1 到 3 张,且提供独立通用素材库", () assert.doesNotMatch(input, /\srequired(?:\s|>)/); } }); + +test("提供独立通用视频生成页签并允许不选择人物素材", () => { + const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8"); + const server = readFileSync(new URL("../server.mjs", import.meta.url), "utf8"); + assert.match(html, /data-module="general-video"/); + assert.match(html, /id="generalVideoForm"/); + assert.match(html, /id="generalAssetPicker"/); + assert.match(html, /id="generalResultList"/); + assert.doesNotMatch(server, /assetReferences\.length\s*<\s*1/); +});