feat: add general video generation workflow
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
# 营响力 AI 短剧数字人 Demo
|
# 营响力 AI 视频与短剧生产 Demo
|
||||||
|
|
||||||
用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。
|
用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。
|
||||||
|
|
||||||
页面拆分为三个一级模块:
|
页面拆分为四个一级模块:
|
||||||
|
|
||||||
- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
|
- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
|
||||||
- **通用素材库**:上传并审核公网图片或 MP4/MOV 视频,保留历史素材,并可作为当前片段的其他参考图片或参考视频。
|
- **通用素材库**:上传并审核公网图片或 MP4/MOV 视频,保留历史素材,并可作为当前片段的其他参考图片或参考视频。
|
||||||
|
- **通用视频生成**:不创建角色和短剧片段,直接输入提示词,选择通用素材或补充公网图片/视频生成单条视频;也支持纯文本生成。
|
||||||
- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
|
- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
|
||||||
|
|
||||||
在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
|
在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交人物素材审核;无需凑齐三张。
|
2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交人物素材审核;无需凑齐三张。
|
||||||
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至本次图片全部 `ready` 或出现 `failed`。
|
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至本次图片全部 `ready` 或出现 `failed`。
|
||||||
4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
|
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`。
|
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` 使用。通用图片用明确的素材用途标记与人物图片分开,避免进入人物组合区。
|
通用素材库同样调用 `POST /v1/assets/upload`:图片传入 `type: "image"`,视频传入 `type: "video"`。审核完成后的 `asset://mat_*` 引用可分别作为当前片段的其他参考图片或 `reference_video` 使用。通用图片用明确的素材用途标记与人物图片分开,避免进入人物组合区。
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
|
|
||||||
已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。
|
已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。
|
||||||
|
|
||||||
短剧、片段、候选版本、任务 ID 和视频地址保存在当前域名的浏览器 `localStorage` 中。普通刷新不会丢失;清理网站数据、使用无痕模式、更换浏览器/设备/域名会丢失本地项目记录。在没有数据库和对象存储的当前版本中,仍建议用户及时下载成品。
|
短剧、片段、候选版本、通用视频生成记录、任务 ID 和视频地址保存在当前域名的浏览器 `localStorage` 中。普通刷新不会丢失;清理网站数据、使用无痕模式、更换浏览器/设备/域名会丢失本地记录。在没有数据库和对象存储的当前版本中,仍建议用户及时下载成品。
|
||||||
|
|
||||||
## 不可见审计日志
|
## 不可见审计日志
|
||||||
|
|
||||||
|
|||||||
397
public/app.js
397
public/app.js
@@ -15,6 +15,8 @@ const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
|
|||||||
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
||||||
const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1";
|
const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1";
|
||||||
const DRAMA_PROJECT_KEY = "yingxiangli.dramaProject.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 VIDEO_KEY_STORAGE = "yingxiangli.videoKey.v1";
|
||||||
const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1";
|
const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1";
|
||||||
const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1";
|
const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1";
|
||||||
@@ -41,6 +43,9 @@ const state = {
|
|||||||
activeShotId: null,
|
activeShotId: null,
|
||||||
activeGenerationShotId: null,
|
activeGenerationShotId: null,
|
||||||
activeGenerationVersionId: null,
|
activeGenerationVersionId: null,
|
||||||
|
generalSelectedReferences: new Set(),
|
||||||
|
generalResults: [],
|
||||||
|
generalPollTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = Object.fromEntries([
|
const el = Object.fromEntries([
|
||||||
@@ -59,6 +64,10 @@ const el = Object.fromEntries([
|
|||||||
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
||||||
"genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus",
|
"genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus",
|
||||||
"genericAssetHint", "genericAssetResult", "genericAssetResultText", "genericAssetList", "genericAssetHistoryCount", "refreshGenericAssets",
|
"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)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
|
|
||||||
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
||||||
@@ -83,6 +92,7 @@ async function init() {
|
|||||||
restoreSceneRoles();
|
restoreSceneRoles();
|
||||||
restoreCharacterLibrary();
|
restoreCharacterLibrary();
|
||||||
restoreDramaProject();
|
restoreDramaProject();
|
||||||
|
restoreGeneralVideoState();
|
||||||
renderConnection();
|
renderConnection();
|
||||||
if (!state.config.mockMode && !currentVideoKey()) {
|
if (!state.config.mockMode && !currentVideoKey()) {
|
||||||
showVideoKeyGate(false);
|
showVideoKeyGate(false);
|
||||||
@@ -95,6 +105,7 @@ async function init() {
|
|||||||
resumePendingReview();
|
resumePendingReview();
|
||||||
resumePendingGenericAsset();
|
resumePendingGenericAsset();
|
||||||
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
||||||
|
if (state.config.mockMode || currentVideoKey()) resumePendingGeneralVideo();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, true);
|
showToast(error.message, true);
|
||||||
el.connectionState.textContent = "本地服务连接失败";
|
el.connectionState.textContent = "本地服务连接失败";
|
||||||
@@ -204,6 +215,16 @@ el.assetForm.addEventListener("submit", async (event) => {
|
|||||||
|
|
||||||
el.genericAssetForm.addEventListener("submit", submitGenericAsset);
|
el.genericAssetForm.addEventListener("submit", submitGenericAsset);
|
||||||
el.genericAssetType.addEventListener("change", updateGenericAssetForm);
|
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) => {
|
el.videoForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -327,6 +348,7 @@ async function loadAssets() {
|
|||||||
renderCharacterLibrary();
|
renderCharacterLibrary();
|
||||||
renderAssetPicker();
|
renderAssetPicker();
|
||||||
renderGenericAssetLibrary();
|
renderGenericAssetLibrary();
|
||||||
|
renderGeneralAssetPicker();
|
||||||
el.assetHistoryCount.textContent = String(state.assets.length);
|
el.assetHistoryCount.textContent = String(state.assets.length);
|
||||||
el.assetList.innerHTML = "";
|
el.assetList.innerHTML = "";
|
||||||
if (!state.assets.length) {
|
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 = '<div class="empty-small">通用素材库暂无可用图片或视频</div>';
|
||||||
|
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 = `<strong>${escapeHtml(name)}</strong><span>${type === "video" ? "视频" : "图片"}</span>`;
|
||||||
|
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 = '<div class="empty-small">提交任务后,生成状态和视频会出现在这里</div>';
|
||||||
|
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 = `<strong>视频 ${state.generalResults.length - index} · ${statusText}</strong><span>${escapeHtml(result.taskId || result.error || "正在获取任务 ID")}</span>`;
|
||||||
|
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) {
|
function useGenericAssetInActiveShot(reference, name, type) {
|
||||||
const shot = activeShot();
|
const shot = activeShot();
|
||||||
if (type === "video") {
|
if (type === "video") {
|
||||||
@@ -629,7 +1024,7 @@ function bindCharacterToActiveRole(character) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchModule(module) {
|
function switchModule(module) {
|
||||||
if (!["library", "generic-library", "production"].includes(module)) return;
|
if (!["library", "generic-library", "general-video", "production"].includes(module)) return;
|
||||||
state.activeModule = module;
|
state.activeModule = module;
|
||||||
document.body.dataset.activeModule = module;
|
document.body.dataset.activeModule = module;
|
||||||
document.querySelectorAll('.module-tab').forEach((button) => {
|
document.querySelectorAll('.module-tab').forEach((button) => {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>营响力|AI 短剧数字人 Demo</title>
|
<title>营响力|AI 视频与短剧生产</title>
|
||||||
<link rel="stylesheet" href="./styles.css?v=25" />
|
<link rel="stylesheet" href="./styles.css?v=26" />
|
||||||
</head>
|
</head>
|
||||||
<body data-active-module="library">
|
<body data-active-module="library">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">营响力 · 真实客户验证</span>
|
<span class="eyebrow">营响力 · 真实客户验证</span>
|
||||||
<h1>AI 短剧数字人生产</h1>
|
<h1>AI 视频与短剧生产</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button class="key-change-button hidden" type="button" id="changeVideoKeyButton">更换客户令牌</button>
|
<button class="key-change-button hidden" type="button" id="changeVideoKeyButton">更换客户令牌</button>
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
<button class="module-tab" type="button" data-module="generic-library">
|
<button class="module-tab" type="button" data-module="generic-library">
|
||||||
<b>通用素材库</b><span>上传、审核并复用图片与视频素材</span>
|
<b>通用素材库</b><span>上传、审核并复用图片与视频素材</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="module-tab" type="button" data-module="general-video">
|
||||||
|
<b>通用视频生成</b><span>直接输入提示词与参考素材生成单条视频</span>
|
||||||
|
</button>
|
||||||
<button class="module-tab" type="button" data-module="production">
|
<button class="module-tab" type="button" data-module="production">
|
||||||
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -200,6 +203,56 @@
|
|||||||
<div class="video-asset-grid" id="genericAssetList"><div class="empty-small">暂无已就绪通用素材</div></div>
|
<div class="video-asset-grid" id="genericAssetList"><div class="empty-small">暂无已就绪通用素材</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="panel-header general-video-only">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">通用视频</span>
|
||||||
|
<h2>生成一条视频</h2>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge neutral" id="generalVideoStatus">可提交</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="generalVideoForm" class="form-grid general-video-only">
|
||||||
|
<label class="field wide">
|
||||||
|
<span>视频提示词</span>
|
||||||
|
<textarea id="generalPrompt" rows="6" placeholder="描述画面主体、场景、动作、镜头、风格与声音。"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section class="general-asset-picker wide">
|
||||||
|
<div class="media-field-head">
|
||||||
|
<div><strong>从通用素材库选择</strong><small>点击素材即可选中或取消</small></div>
|
||||||
|
<button class="media-add-button" type="button" id="refreshGeneralAssets">刷新素材</button>
|
||||||
|
</div>
|
||||||
|
<div class="general-asset-grid" id="generalAssetPicker"><div class="empty-small">暂无通用素材</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="multimodal-grid wide">
|
||||||
|
<div class="field media-field">
|
||||||
|
<div class="media-field-head"><span>其他参考图片</span><button class="media-add-button" type="button" id="addGeneralImageButton">+ 添加图片</button></div>
|
||||||
|
<div class="media-url-list" id="generalImageInputs"></div>
|
||||||
|
<small>可填写公网图片 URL 或 asset:// 素材引用,最多 9 张。</small>
|
||||||
|
</div>
|
||||||
|
<div class="field media-field">
|
||||||
|
<div class="media-field-head"><span>参考视频</span><button class="media-add-button" type="button" id="addGeneralVideoButton">+ 添加视频</button></div>
|
||||||
|
<div class="media-url-list" id="generalVideoInputs"></div>
|
||||||
|
<small>可填写公网 MP4/MOV 或 asset:// 素材引用,最多 3 条。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-quota wide valid" id="generalInputQuota">参考图片 0/9 · 参考视频 0/3</div>
|
||||||
|
|
||||||
|
<label class="field"><span>模型</span><select id="generalModel"><option value="doubao-seedance-2-0-260128">Seedance 2.0 · 标准</option><option value="doubao-seedance-2-0-fast-260128">Seedance 2.0 · 快速</option><option value="doubao-seedance-2-0-mini-260615">Seedance 2.0 · Mini</option></select></label>
|
||||||
|
<label class="field"><span>画面比例</span><select id="generalRatio"><option value="9:16">9:16 · 竖屏</option><option value="16:9">16:9 · 横屏</option><option value="1:1">1:1 · 方形</option><option value="adaptive">跟随参考素材</option></select></label>
|
||||||
|
<label class="field"><span>时长</span><select id="generalDuration"><option value="5">5 秒</option><option value="6">6 秒</option><option value="7">7 秒</option><option value="8">8 秒</option><option value="9">9 秒</option><option value="10">10 秒</option><option value="11">11 秒</option><option value="12">12 秒</option><option value="13">13 秒</option><option value="14">14 秒</option></select></label>
|
||||||
|
<label class="field"><span>分辨率</span><select id="generalResolution"><option value="720p">720p</option><option value="480p">480p</option></select></label>
|
||||||
|
<label class="toggle-row"><input id="generalGenerateAudio" type="checkbox" checked /><span>生成同步音频</span></label>
|
||||||
|
<label class="toggle-row"><input id="generalWatermark" type="checkbox" /><span>保留平台水印</span></label>
|
||||||
|
<div class="field action-field wide"><button class="primary video-button" type="submit" id="generalGenerateButton">生成视频</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="general-video-results general-video-only">
|
||||||
|
<div class="version-head"><div><span class="section-kicker">生成记录</span><h3>本浏览器生成的视频</h3></div><span id="generalResultCount">暂无记录</span></div>
|
||||||
|
<div class="version-list" id="generalResultList"><div class="empty-small">提交任务后,生成状态和视频会出现在这里</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="storyboard production-only">
|
<div class="storyboard production-only">
|
||||||
<label class="field continuity-choice">
|
<label class="field continuity-choice">
|
||||||
<span>与上一片段的衔接</span>
|
<span>与上一片段的衔接</span>
|
||||||
@@ -351,6 +404,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast hidden" id="toast"></div>
|
<div class="toast hidden" id="toast"></div>
|
||||||
<script src="./app.js?v=34"></script>
|
<script src="./app.js?v=35"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ button { cursor: pointer; }
|
|||||||
main { max-width: 1480px; margin: 0 auto; padding: 24px; }
|
main { max-width: 1480px; margin: 0 auto; padding: 24px; }
|
||||||
.panel, .sidebar { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
|
.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 { 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 b { color: var(--ink); font-size: 16px; }
|
||||||
.module-tab span { font-size: 12px; }
|
.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); }
|
.module-tab.active b { color: var(--blue); }
|
||||||
body[data-active-module="library"] .production-only,
|
body[data-active-module="library"] .production-only,
|
||||||
body[data-active-module="library"] .generic-library-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"] .library-only,
|
||||||
body[data-active-module="production"] .generic-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"] .library-only,
|
||||||
body[data-active-module="generic-library"] .production-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; }
|
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); }
|
.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-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 { 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; }
|
.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 { 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; }
|
.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; }
|
.view-grid { grid-template-columns: 1fr; }
|
||||||
.multimodal-grid { grid-template-columns: 1fr; }
|
.multimodal-grid { grid-template-columns: 1fr; }
|
||||||
.video-asset-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; }
|
.field.wide { grid-column: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,8 +560,8 @@ function buildVideoPayload(body) {
|
|||||||
const extraImageReferences = Array.isArray(body.extraImageReferences) ? body.extraImageReferences : [];
|
const extraImageReferences = Array.isArray(body.extraImageReferences) ? body.extraImageReferences : [];
|
||||||
const videoReferences = Array.isArray(body.videoReferences) ? body.videoReferences : [];
|
const videoReferences = Array.isArray(body.videoReferences) ? body.videoReferences : [];
|
||||||
const imageReferences = [...assetReferences, ...extraImageReferences];
|
const imageReferences = [...assetReferences, ...extraImageReferences];
|
||||||
if (assetReferences.length < 1 || assetReferences.some((item) => !/^asset:\/\/(?:mat_|asset-)/i.test(item))) {
|
if (assetReferences.some((item) => !/^asset:\/\/(?:mat_|asset-)/i.test(item))) {
|
||||||
throw new ClientError("请先选择审核通过的人物素材(asset://...)");
|
throw new ClientError("人物素材必须是审核通过的 asset:// 引用");
|
||||||
}
|
}
|
||||||
if (imageReferences.length > 9) {
|
if (imageReferences.length > 9) {
|
||||||
throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`);
|
throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`);
|
||||||
|
|||||||
@@ -44,3 +44,13 @@ test("人物素材支持任选 1 到 3 张,且提供独立通用素材库", ()
|
|||||||
assert.doesNotMatch(input, /\srequired(?:\s|>)/);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user