Publish current ComfyUI O1Key code baseline

Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
This commit is contained in:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+795
View File
@@ -0,0 +1,795 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// ── 适用节点 ──────────────────────────────────────────────────────────────
const TARGET_NODES = ["K3Video"];
const STYLE_ID = "o1key-elem-styles";
const DIALOG_ID = "o1key-elem-dialog";
let dialogRoot = null;
let activeNode = null;
let currentTab = "mine";
let myElements = []; // /mine 列表缓存
let createRefType = "image"; // 参考方式:image=多图主体, video=视频主体
let createRefs = []; // 创建表单:参考图 { file, preview } 列表(点创建时才上传)
let createFrontal = null; // 创建表单:正面图 { file, preview }
let createVideo = null; // 创建表单:参考视频 { file, preview }
let createTags = []; // 创建表单:已选标签 id 列表(如 o_101)
let pollTimer = null; // 轮询定时器
// 主体标签(对应腾讯云 TagList,o_101~o_108)
const ELEMENT_TAGS = [
{ id: "o_101", name: "热梗" },
{ id: "o_102", name: "人物" },
{ id: "o_103", name: "动物" },
{ id: "o_104", name: "道具" },
{ id: "o_105", name: "服饰" },
{ id: "o_106", name: "场景" },
{ id: "o_107", name: "特效" },
{ id: "o_108", name: "其他" },
];
// 记录每个节点最近聚焦的提示词框 widget 名(与 promptLibrary 同思路,但本文件独立)
const lastFocused = new WeakMap();
const CSS = `
#o1key-elem-dialog{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.52);color:#ddd;font-family:inherit}
#o1key-elem-modal{width:min(560px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));display:flex;flex-direction:column;overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:10px;background:var(--comfy-menu-bg,#1a1a1a);box-shadow:0 24px 80px rgba(0,0,0,.5)}
#o1key-elem-header{height:50px;padding:0 16px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
#o1key-elem-title{font-size:15px;font-weight:700;color:#eee}
.o1e-icon-btn{width:30px;height:30px;border:1px solid rgba(255,255,255,.12);border-radius:6px;background:rgba(255,255,255,.04);color:#aaa;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s}
.o1e-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
#o1e-tabs{display:flex;gap:18px;padding:10px 16px 0;flex-shrink:0;border-bottom:1px solid rgba(255,255,255,.06)}
.o1e-tab{background:none;border:none;color:#888;font-size:14px;font-weight:600;padding:6px 2px 10px;cursor:pointer;border-bottom:2px solid transparent;font-family:inherit}
.o1e-tab.active{color:#fff;border-bottom-color:#fff}
#o1e-body{flex:1;min-height:0;overflow:auto;padding:14px 16px}
#o1e-body::-webkit-scrollbar{width:6px}
#o1e-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
.o1e-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.o1e-card{border:1px solid rgba(255,255,255,.1);border-radius:8px;overflow:hidden;background:rgba(255,255,255,.03);transition:all .12s;display:flex;flex-direction:column}
.o1e-card:hover{border-color:rgba(255,255,255,.22)}
.o1e-thumb{width:100%;aspect-ratio:1;object-fit:cover;background:#111;cursor:pointer;display:block}
.o1e-card-body{padding:8px 10px;display:flex;flex-direction:column;gap:4px}
.o1e-card-name{font-size:13px;font-weight:700;color:#eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1e-card-status{font-size:11px}
.o1e-st-succeed{color:#4ade80}
.o1e-st-pending{color:#e0b341}
.o1e-st-failed{color:#df7777}
.o1e-card-actions{display:flex;gap:6px;margin-top:4px}
.o1e-mini-btn{flex:1;padding:5px 0;border-radius:5px;font-size:12px;cursor:pointer;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#ccc;font-family:inherit}
.o1e-mini-btn:hover{background:rgba(255,255,255,.1);color:#fff}
.o1e-mini-btn.danger:hover{background:rgba(223,119,119,.15);color:#f3a0a0;border-color:rgba(223,119,119,.4)}
.o1e-mini-btn.referenced{background:rgba(74,222,128,.16);color:#4ade80;border-color:rgba(74,222,128,.45)}
.o1e-mini-btn.referenced:hover{background:rgba(74,222,128,.24);color:#6ee79a}
.o1e-card.is-ref{border-color:rgba(74,222,128,.5);box-shadow:0 0 0 1px rgba(74,222,128,.25) inset}
.o1e-ref-badge{position:absolute;top:6px;left:6px;width:20px;height:20px;border-radius:50%;background:#22a559;color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.o1e-thumb-wrap{position:relative}
.o1e-empty{padding:40px 0;text-align:center;color:#777;font-size:13px;line-height:1.8}
.o1e-field{margin-bottom:14px}
.o1e-label{display:block;font-size:12px;color:#bbb;margin-bottom:6px}
.o1e-label .req{color:#df7777;margin-left:2px}
.o1e-input,.o1e-textarea{width:100%;box-sizing:border-box;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.14);border-radius:6px;color:#eee;font-size:13px;padding:8px 10px;font-family:inherit}
.o1e-textarea{resize:vertical;min-height:54px}
.o1e-input:focus,.o1e-textarea:focus{outline:none;border-color:rgba(255,255,255,.32)}
.o1e-imgrow{display:flex;flex-wrap:wrap;gap:8px}
.o1e-imgbox{position:relative;width:72px;height:72px;border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,.14);background:#111}
.o1e-imgbox img{width:100%;height:100%;object-fit:cover}
.o1e-imgbox .del{position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:4px;background:rgba(0,0,0,.6);color:#fff;border:none;cursor:pointer;font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center}
.o1e-addbox{width:72px;height:72px;border-radius:6px;border:1px dashed rgba(255,255,255,.25);background:rgba(255,255,255,.03);color:#888;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:24px}
.o1e-addbox:hover{color:#fff;border-color:rgba(255,255,255,.4)}
.o1e-addbox.busy{opacity:.5;pointer-events:none}
#o1e-footer{flex-shrink:0;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:flex;flex-direction:column;gap:8px}
#o1e-status{font-size:12px;color:#9a9a9a;min-height:16px}
#o1e-status[data-kind="error"]{color:#df7777}
#o1e-status[data-kind="success"]{color:#63bf8a}
#o1e-footer-row{display:flex;gap:8px;align-items:center;justify-content:flex-end}
.o1e-btn{padding:8px 18px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;font-family:inherit;font-weight:600}
.o1e-btn-ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);color:#bbb}
.o1e-btn-ghost:hover{background:rgba(255,255,255,.1);color:#eee}
.o1e-btn-primary{background:#fff;color:#111}
.o1e-btn-primary:hover{background:#e6e6e6}
.o1e-btn-primary:disabled{opacity:.5;cursor:not-allowed}
.o1e-seg{display:flex;gap:0;border:1px solid rgba(255,255,255,.14);border-radius:6px;overflow:hidden;width:fit-content}
.o1e-seg button{background:rgba(255,255,255,.03);border:none;color:#aaa;font-size:12px;padding:7px 16px;cursor:pointer;font-family:inherit}
.o1e-seg button.active{background:#fff;color:#111;font-weight:600}
.o1e-hint{font-size:11px;color:#777;margin-top:6px;line-height:1.6}
.o1e-tags{display:flex;flex-wrap:wrap;gap:7px}
.o1e-tag-chip{padding:5px 12px;border-radius:14px;font-size:12px;cursor:pointer;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.04);color:#bbb;font-family:inherit}
.o1e-tag-chip.on{background:rgba(255,255,255,.92);color:#111;border-color:#fff;font-weight:600}
.o1e-videocard{position:relative;display:flex;align-items:center;gap:10px;width:100%;max-width:320px;box-sizing:border-box;padding:10px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.04)}
.o1e-videocard-icon{font-size:24px;line-height:1;flex-shrink:0}
.o1e-videocard-info{min-width:0;flex:1}
.o1e-videocard-name{font-size:12px;color:#eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1e-videocard-size{font-size:11px;color:#888;margin-top:2px}
.o1e-videocard .del{position:absolute;top:6px;right:6px;width:18px;height:18px;border-radius:4px;background:rgba(0,0,0,.6);color:#fff;border:none;cursor:pointer;font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center}
.o1e-addbox.video{width:140px;height:90px}
`;
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
el.id = STYLE_ID;
el.textContent = CSS;
document.head.appendChild(el);
}
function escapeHtml(v) {
return String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function iconClose() {
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
}
function setStatus(msg, kind = "muted") {
const el = dialogRoot?.querySelector("#o1e-status");
if (!el) return;
el.textContent = msg || "";
el.dataset.kind = kind;
}
// ── 后端代理调用 ──────────────────────────────────────────────────────────
async function apiGetMine(includeAll = false) {
const q = includeAll ? "?include_all=true" : "";
const resp = await api.fetchApi(`/o1key/element/mine${q}`);
const data = await resp.json();
if (!data.success) throw new Error(data.message || "获取主体列表失败");
return Array.isArray(data.data) ? data.data : [];
}
async function apiUpload(file) {
const form = new FormData();
form.append("file", file);
const resp = await api.fetchApi("/o1key/element/upload", { method: "POST", body: form });
const data = await resp.json();
if (!data.success) throw new Error(data.message || "图片上传失败");
return data.data; // { url, ... }
}
async function apiCreate(payload) {
const resp = await api.fetchApi("/o1key/element/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.success) throw new Error(data.message || "创建主体失败");
return data.data; // { id, element_id, status, ... }
}
async function apiRefresh(taskId) {
const resp = await api.fetchApi("/o1key/element/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_id: taskId }),
});
const data = await resp.json();
if (!data.success) throw new Error(data.message || "刷新状态失败");
return data.data; // { element, detail }
}
async function apiDelete(elementId) {
const resp = await api.fetchApi("/o1key/element/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ element_id: elementId }),
});
const data = await resp.json();
if (!data.success) throw new Error(data.message || "删除主体失败");
return true;
}
// ── 提示词框定位(注入【@名称】)──────────────────────────────────────────
function isPromptWidget(w) {
if (!w || typeof w.name !== "string") return false;
const n = w.name;
if (!n.includes("提示词") && !/prompt/i.test(n)) return false;
if (n.includes("负向") || /negative/i.test(n)) return false;
return true;
}
function listPromptWidgets(node) {
return (node?.widgets || []).filter(isPromptWidget);
}
function resolveTargetWidget(node) {
const widgets = listPromptWidgets(node);
if (!widgets.length) return null;
const remembered = lastFocused.get(node);
if (remembered) {
const hit = widgets.find(w => w.name === remembered);
if (hit) return hit;
}
return widgets.find(w => w.name.includes("正向") || w.name === "提示词") || widgets[0];
}
function trackFocus(node) {
for (const w of listPromptWidgets(node)) {
const el = w.element || w.inputEl;
if (!el || el.dataset?.o1eFocusBound) continue;
el.dataset.o1eFocusBound = "1";
el.addEventListener("focus", () => lastFocused.set(node, w.name));
}
}
// 收集当前节点所有提示词框中已引用的主体名称集合(用于显示"已引用"状态)
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getReferencedNames(node) {
const names = new Set();
if (!node) return names;
const re = /【@([^】]+)】/g;
for (const w of listPromptWidgets(node)) {
const txt = String(w.value || "");
let m;
while ((m = re.exec(txt)) !== null) names.add(m[1].trim());
}
return names;
}
// 把【@名称】插入到目标提示词框
function insertReference(name) {
if (!activeNode) return;
const widget = resolveTargetWidget(activeNode);
if (!widget) { setStatus("未找到可注入的提示词框", "error"); return; }
const token = `【@${name}】`;
const cur = String(widget.value || "");
const next = cur ? (/[\s,,。.]$/.test(cur) ? cur + token : cur + token) : token;
widget.value = next;
const el = widget.element || widget.inputEl;
if (el && "value" in el) el.value = next;
widget.callback?.(next);
activeNode.setDirtyCanvas?.(true, true);
setStatus(`已插入【@${name}】到「${widget.name}」`, "success");
refreshRefBadges();
}
// 从所有提示词框移除某主体的全部【@名称】引用
function removeReference(name) {
if (!activeNode) return;
const re = new RegExp(`【@${escapeRegExp(name)}】`, "g");
let removed = 0;
for (const w of listPromptWidgets(activeNode)) {
const cur = String(w.value || "");
if (!re.test(cur)) continue;
re.lastIndex = 0;
// 连带清掉引用前后可能多出的一个分隔符,避免留下空逗号
const next = cur.replace(new RegExp(`\\s*[,,]?\\s*【@${escapeRegExp(name)}】`, "g"), "")
.replace(new RegExp(`【@${escapeRegExp(name)}】\\s*[,,]?\\s*`, "g"), "");
if (next === cur) continue;
w.value = next;
const el = w.element || w.inputEl;
if (el && "value" in el) el.value = next;
w.callback?.(next);
removed += 1;
}
activeNode.setDirtyCanvas?.(true, true);
setStatus(removed ? `已取消引用【@${name}】` : `未找到【@${name}】`, removed ? "muted" : "error");
refreshRefBadges();
}
// 点"引用"按钮:已引用则取消,未引用则插入
function toggleReference(name) {
if (getReferencedNames(activeNode).has(name)) removeReference(name);
else insertReference(name);
}
// 根据当前提示词内容,刷新卡片上的"已引用"勾选状态(不重新拉列表)
function refreshRefBadges() {
if (!dialogRoot) return;
const refs = getReferencedNames(activeNode);
dialogRoot.querySelectorAll(".o1e-card[data-cardname]").forEach(card => {
const on = refs.has(card.dataset.cardname);
card.classList.toggle("is-ref", on);
const badge = card.querySelector(".o1e-ref-badge");
if (badge) badge.style.display = on ? "flex" : "none";
const btn = card.querySelector("[data-use]");
if (btn) {
btn.classList.toggle("referenced", on);
btn.textContent = on ? "✓ 已引用" : "引用";
}
});
}
// ── 渲染:我的主体 ────────────────────────────────────────────────────────
const STATUS_LABEL = { succeed: "可用", pending: "生成中…", failed: "失败" };
async function loadMine() {
setStatus("正在加载主体列表…");
try {
myElements = await apiGetMine(true); // 含 pending/failed,便于看进度
renderMineTab();
setStatus(myElements.length ? `共 ${myElements.length} 个主体` : "");
} catch (e) {
myElements = [];
renderMineTab();
setStatus(e.message || "加载失败", "error");
}
}
// 外链缩略图经后端同源代理,绕过 ComfyUI 的 CSP(img-src 'self')限制
function proxyImage(url) {
if (!url) return "";
if (/^(data:|blob:)/.test(url)) return url; // 本地预览无需代理
return `/o1key/element/image?url=${encodeURIComponent(url)}`;
}
function renderMineTab() {
const body = dialogRoot.querySelector("#o1e-body");
if (!myElements.length) {
body.innerHTML = `<div class="o1e-empty">还没有主体<br>切到「创建主体」上传参考图来创建</div>`;
return;
}
const cards = myElements.map(e => {
const st = e.status || "succeed";
const stCls = st === "succeed" ? "o1e-st-succeed" : st === "failed" ? "o1e-st-failed" : "o1e-st-pending";
const stTxt = STATUS_LABEL[st] || st;
const inner = e.frontal_image
? `<img class="o1e-thumb" src="${escapeHtml(proxyImage(e.frontal_image))}" data-name="${escapeHtml(e.name)}" data-status="${escapeHtml(st)}" loading="lazy" decoding="async">`
: `<div class="o1e-thumb" data-name="${escapeHtml(e.name)}" data-status="${escapeHtml(st)}"></div>`;
const thumb = `<div class="o1e-thumb-wrap">${inner}<div class="o1e-ref-badge" style="display:none">✓</div></div>`;
const useBtn = st === "succeed"
? `<button class="o1e-mini-btn" data-use="${escapeHtml(e.name)}">引用</button>`
: "";
const delBtn = e.element_id
? `<button class="o1e-mini-btn danger" data-del="${escapeHtml(e.element_id)}" data-delname="${escapeHtml(e.name)}">删除</button>`
: "";
return `<div class="o1e-card" data-cardname="${escapeHtml(e.name)}">
${thumb}
<div class="o1e-card-body">
<div class="o1e-card-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</div>
<div class="o1e-card-status ${stCls}">${escapeHtml(stTxt)}</div>
<div class="o1e-card-actions">${useBtn}${delBtn}</div>
</div>
</div>`;
}).join("");
body.innerHTML = `<div class="o1e-grid">${cards}</div>`;
body.querySelectorAll("[data-use]").forEach(b =>
b.addEventListener("click", () => toggleReference(b.dataset.use)));
body.querySelectorAll(".o1e-thumb[data-status='succeed']").forEach(t =>
t.addEventListener("click", () => toggleReference(t.dataset.name)));
body.querySelectorAll("[data-del]").forEach(b =>
b.addEventListener("click", () => deleteElement(b.dataset.del, b.dataset.delname)));
refreshRefBadges();
}
async function deleteElement(elementId, name) {
if (!confirm(`确定删除主体「${name}」?此操作会同时删除云端主体,不可恢复。`)) return;
setStatus(`正在删除「${name}」…`);
try {
await apiDelete(elementId);
setStatus(`已删除「${name}」`, "success");
await loadMine();
} catch (e) {
setStatus(e.message || "删除失败", "error");
}
}
// ── 渲染:创建主体 ────────────────────────────────────────────────────────
const pendingForm = { name: "", desc: "", voiceId: "" }; // 创建表单文本暂存(切标签不丢)
function renderCreateTab() {
const body = dialogRoot.querySelector("#o1e-body");
body.innerHTML = `
<div class="o1e-field">
<label class="o1e-label">参考方式<span class="req">*</span></label>
<div class="o1e-seg" id="o1e-reftype">
<button data-rt="image" class="${createRefType === "image" ? "active" : ""}">多图主体</button>
<button data-rt="video" class="${createRefType === "video" ? "active" : ""}">视频主体</button>
</div>
<div class="o1e-hint" id="o1e-rt-hint"></div>
</div>
<div class="o1e-field">
<label class="o1e-label">主体名称<span class="req">*</span>(≤20字,即提示词里【@名称】用的标签)</label>
<input class="o1e-input" id="o1e-name" maxlength="20" placeholder="例如:模特B">
</div>
<div class="o1e-field">
<label class="o1e-label">描述<span class="req">*</span>(≤100字)</label>
<textarea class="o1e-textarea" id="o1e-desc" maxlength="100" placeholder="例如:一个穿红裙的年轻女性,长发"></textarea>
</div>
<div id="o1e-refer-area"></div>
<div class="o1e-field">
<label class="o1e-label">主体音色(选填,仅人物/类人主体可绑定)</label>
<input class="o1e-input" id="o1e-voice" placeholder="音色库中的音色ID,留空则不绑定">
</div>
<div class="o1e-field">
<label class="o1e-label">标签(选填,可多选)</label>
<div class="o1e-tags" id="o1e-tags"></div>
</div>`;
renderReferArea();
renderTags();
const nameEl = dialogRoot.querySelector("#o1e-name");
const descEl = dialogRoot.querySelector("#o1e-desc");
const voiceEl = dialogRoot.querySelector("#o1e-voice");
nameEl.value = pendingForm.name || "";
descEl.value = pendingForm.desc || "";
voiceEl.value = pendingForm.voiceId || "";
nameEl.addEventListener("input", e => pendingForm.name = e.target.value);
descEl.addEventListener("input", e => pendingForm.desc = e.target.value);
voiceEl.addEventListener("input", e => pendingForm.voiceId = e.target.value);
dialogRoot.querySelectorAll("#o1e-reftype button").forEach(b =>
b.addEventListener("click", () => {
if (createRefType === b.dataset.rt) return;
createRefType = b.dataset.rt;
dialogRoot.querySelectorAll("#o1e-reftype button").forEach(x =>
x.classList.toggle("active", x.dataset.rt === createRefType));
renderReferArea();
}));
}
// 参考区:按 image / video 切换
function renderReferArea() {
const area = dialogRoot.querySelector("#o1e-refer-area");
const hint = dialogRoot.querySelector("#o1e-rt-hint");
if (!area) return;
if (createRefType === "video") {
if (hint) hint.textContent = "MP4/MOV,3~8秒,16:9 或 9:16,1080P,≤200MB;有声视频含人声会触发音色定制。";
area.innerHTML = `
<div class="o1e-field">
<label class="o1e-label">参考视频<span class="req">*</span>(1段)</label>
<div class="o1e-imgrow" id="o1e-video-row"></div>
</div>`;
renderVideoRow();
} else {
if (hint) hint.textContent = "用多张图片设定主体外观:1张正面图 + 1~3张其他角度/特写图。jpg/png,≤10MB,≥300px,宽高比 1:2.5~2.5:1。";
area.innerHTML = `
<div class="o1e-field">
<label class="o1e-label">正面参考图<span class="req">*</span>(1张)</label>
<div class="o1e-imgrow" id="o1e-frontal-row"></div>
</div>
<div class="o1e-field">
<label class="o1e-label">其他视角图<span class="req">*</span>(1~3张)</label>
<div class="o1e-imgrow" id="o1e-refer-row"></div>
</div>`;
renderFrontalRow();
renderReferRow();
}
}
function renderTags() {
const box = dialogRoot.querySelector("#o1e-tags");
if (!box) return;
box.innerHTML = ELEMENT_TAGS.map(t =>
`<button class="o1e-tag-chip ${createTags.includes(t.id) ? "on" : ""}" data-tag="${t.id}">${escapeHtml(t.name)}</button>`).join("");
box.querySelectorAll("[data-tag]").forEach(b =>
b.addEventListener("click", () => {
const id = b.dataset.tag;
const i = createTags.indexOf(id);
if (i >= 0) createTags.splice(i, 1); else createTags.push(id);
b.classList.toggle("on");
}));
}
function renderFrontalRow() {
const row = dialogRoot.querySelector("#o1e-frontal-row");
if (!row) return;
if (createFrontal) {
row.innerHTML = `<div class="o1e-imgbox"><img src="${escapeHtml(createFrontal.preview)}"><button class="del" data-delfrontal>×</button></div>`;
row.querySelector("[data-delfrontal]").addEventListener("click", () => { revokePreview(createFrontal); createFrontal = null; renderFrontalRow(); });
} else {
row.innerHTML = `<div class="o1e-addbox" data-addfrontal>+</div>`;
row.querySelector("[data-addfrontal]").addEventListener("click", () => pickImage(item => { createFrontal = item; renderFrontalRow(); }));
}
}
function renderReferRow() {
const row = dialogRoot.querySelector("#o1e-refer-row");
if (!row) return;
let html = createRefs.map((it, i) =>
`<div class="o1e-imgbox"><img src="${escapeHtml(it.preview)}"><button class="del" data-delrefer="${i}">×</button></div>`).join("");
if (createRefs.length < 3) html += `<div class="o1e-addbox" data-addrefer>+</div>`;
row.innerHTML = html;
row.querySelectorAll("[data-delrefer]").forEach(b =>
b.addEventListener("click", () => { const i = Number(b.dataset.delrefer); revokePreview(createRefs[i]); createRefs.splice(i, 1); renderReferRow(); }));
const add = row.querySelector("[data-addrefer]");
if (add) add.addEventListener("click", () => pickImage(item => { createRefs.push(item); renderReferRow(); }));
}
function renderVideoRow() {
const row = dialogRoot.querySelector("#o1e-video-row");
if (!row) return;
if (createVideo) {
// CSP 禁止加载 blob: 媒体,改用文件卡片(图标+文件名+大小),不嵌入 <video>
const sizeMb = (createVideo.size / 1024 / 1024).toFixed(1);
row.innerHTML = `<div class="o1e-videocard">
<div class="o1e-videocard-icon">🎬</div>
<div class="o1e-videocard-info">
<div class="o1e-videocard-name" title="${escapeHtml(createVideo.name)}">${escapeHtml(createVideo.name)}</div>
<div class="o1e-videocard-size">${sizeMb} MB · 已选择,点「创建主体」时上传</div>
</div>
<button class="del" data-delvideo>×</button>
</div>`;
row.querySelector("[data-delvideo]").addEventListener("click", () => { createVideo = null; renderVideoRow(); });
} else {
row.innerHTML = `<div class="o1e-addbox video" data-addvideo>+</div>`;
row.querySelector("[data-addvideo]").addEventListener("click", () => pickVideo(item => { createVideo = item; renderVideoRow(); }));
}
}
// 主体参考图合规约束(与腾讯云 CreateAigcElement 文档一致)
const IMG_MAX_BYTES = 10 * 1024 * 1024; // 文件 ≤ 10MB
const IMG_MIN_SIDE = 300; // 宽、高均 ≥ 300px
const IMG_MIN_RATIO = 1 / 2.5; // 宽高比 1:2.5 ~ 2.5:1
const IMG_MAX_RATIO = 2.5;
const IMG_ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png"];
// 选图前本地校验:格式 / 大小 / 尺寸 / 宽高比。返回 { ok, reason }
function validateImageFile(file, preview) {
return new Promise((resolve) => {
const type = (file.type || "").toLowerCase();
if (type && !IMG_ALLOWED_TYPES.includes(type)) {
return resolve({ ok: false, reason: "仅支持 jpg / jpeg / png 格式" });
}
if (file.size > IMG_MAX_BYTES) {
return resolve({ ok: false, reason: `图片不能超过 10MB(当前 ${(file.size / 1024 / 1024).toFixed(1)}MB)` });
}
const img = new Image();
img.onload = () => {
const w = img.naturalWidth, h = img.naturalHeight;
if (w < IMG_MIN_SIDE || h < IMG_MIN_SIDE) {
return resolve({ ok: false, reason: `图片宽高需 ≥ 300px(当前 ${w}×${h})` });
}
const ratio = w / h;
if (ratio < IMG_MIN_RATIO || ratio > IMG_MAX_RATIO) {
return resolve({ ok: false, reason: `图片宽高比需在 1:2.5 ~ 2.5:1 之间(当前 ${w}×${h})` });
}
resolve({ ok: true });
};
img.onerror = () => resolve({ ok: false, reason: "图片读取失败,请换一张" });
img.src = preview;
});
}
// 选本地图片 → 本地校验通过后仅本地预览(不上传),存 { file, preview }
function pickImage(onDone) {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/jpeg,image/jpg,image/png";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const preview = URL.createObjectURL(file);
const verdict = await validateImageFile(file, preview);
if (!verdict.ok) {
try { URL.revokeObjectURL(preview); } catch (e) {}
setStatus(verdict.reason, "error");
return;
}
onDone({ file, preview });
setStatus("已选择图片(点「创建主体」时上传)");
};
input.click();
}
// 主体参考视频约束(与腾讯云 CreateAigcElement 文档一致)
const VIDEO_MAX_BYTES = 200 * 1024 * 1024; // ≤ 200MB
const VIDEO_ALLOWED_TYPES = ["video/mp4", "video/quicktime"]; // MP4 / MOV
// 选视频前本地校验:仅格式 / 大小(时长、宽高比需加载 <video>,被 CSP 拦,交云端校验)
function validateVideoFile(file) {
const type = (file.type || "").toLowerCase();
const nameOk = /\.(mp4|mov)$/i.test(file.name || "");
if (type && !VIDEO_ALLOWED_TYPES.includes(type) && !nameOk) {
return { ok: false, reason: "仅支持 MP4 / MOV 格式" };
}
if (file.size > VIDEO_MAX_BYTES) {
return { ok: false, reason: `视频不能超过 200MB(当前 ${(file.size / 1024 / 1024).toFixed(0)}MB)` };
}
return { ok: true };
}
// 选本地视频 → 本地校验通过后存 { file, name, size }(不预览播放,受 CSP 限制)
function pickVideo(onDone) {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/mp4,video/quicktime";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
const verdict = validateVideoFile(file);
if (!verdict.ok) {
setStatus(verdict.reason, "error");
return;
}
onDone({ file, name: file.name, size: file.size });
setStatus("已选择视频(点「创建主体」时上传)");
};
input.click();
}
function revokePreview(item) {
if (item?.preview) { try { URL.revokeObjectURL(item.preview); } catch (e) {} }
}
// 提交创建
async function submitCreate() {
const name = (pendingForm.name || "").trim();
const desc = (pendingForm.desc || "").trim();
const voiceId = (pendingForm.voiceId || "").trim();
if (!name) return setStatus("请填写主体名称", "error");
if (!desc) return setStatus("请填写描述", "error");
if (createRefType === "video") {
if (!createVideo) return setStatus("请上传 1 段参考视频", "error");
} else {
if (!createFrontal) return setStatus("请上传 1 张正面参考图", "error");
if (createRefs.length < 1) return setStatus("请至少上传 1 张其他视角图", "error");
}
const submitBtn = dialogRoot.querySelector("[data-submit]");
if (submitBtn) submitBtn.disabled = true;
try {
const payload = {
name, description: desc,
reference_type: createRefType === "video" ? "video_refer" : "image_refer",
};
if (createRefType === "video") {
// 1. 上传视频
setStatus("正在上传视频…");
payload.video_list = [(await apiUpload(createVideo.file)).url];
} else {
// 1. 先统一上传所有图片,拿到公网 URL
setStatus("正在上传图片…");
payload.frontal_image = (await apiUpload(createFrontal.file)).url;
const referUrls = [];
for (let i = 0; i < createRefs.length; i++) {
setStatus(`正在上传视角图 ${i + 1}/${createRefs.length}…`);
referUrls.push((await apiUpload(createRefs[i].file)).url);
}
payload.refer_images = referUrls;
}
if (voiceId) payload.element_voice_id = voiceId;
if (createTags.length) payload.tag_ids = [...createTags];
// 2. 提交创建
setStatus("正在创建主体…");
const created = await apiCreate(payload);
setStatus(`主体「${name}」已提交,等待云端生成…`, "success");
// 重置表单(释放本地预览 URL;视频无 blob 预览,无需释放)
pendingForm.name = ""; pendingForm.desc = ""; pendingForm.voiceId = "";
revokePreview(createFrontal); createRefs.forEach(revokePreview);
createFrontal = null; createRefs = []; createVideo = null; createTags = [];
// 切到列表并开始轮询该主体状态(使用 job_id 作为 task_id)
switchTab("mine");
await loadMine();
if (created?.job_id) startPolling(created.job_id, name);
} catch (e) {
setStatus(e.message || "创建失败", "error");
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
// 轮询某主体直到 succeed/failed(节奏:首次 8s,之后 15s,最多约 5 分钟)
// 优化:只在面板打开且为当前活跃标签时才轮询,避免后台持续请求
function startPolling(taskId, name) {
if (pollTimer) clearTimeout(pollTimer);
let tries = 0;
const tick = async () => {
// 如果面板已关闭或不再是当前标签,停止轮询
if (!dialogRoot || currentTab !== "mine") {
pollTimer = null;
return;
}
tries += 1;
try {
const data = await apiRefresh(taskId);
const st = data?.element?.status || data?.element?.task_status;
if (st === "succeed") {
setStatus(`主体「${name}」已生成完成,可引用了`, "success");
await loadMine();
return;
}
if (st === "failed") {
setStatus(`主体「${name}」生成失败:${data?.element?.fail_reason || "未知原因"}`, "error");
await loadMine();
return;
}
} catch (e) {
// 单次刷新失败不终止,继续重试
}
if (tries >= 20) { setStatus(`主体「${name}」仍在生成,请稍后手动刷新`, "muted"); return; }
// 只在状态仍为 pending 时才重新加载列表,避免每次都刷新
if (tries === 1 || tries % 3 === 0) { // 每 3 次轮询才刷新一次列表
await loadMine();
}
pollTimer = setTimeout(tick, tries === 1 ? 8000 : 15000);
};
pollTimer = setTimeout(tick, 8000);
}
// ── 面板开关 ──────────────────────────────────────────────────────────────
function switchTab(tab) {
currentTab = tab;
dialogRoot.querySelectorAll(".o1e-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
const footer = dialogRoot.querySelector("#o1e-footer-row");
if (tab === "mine") {
renderMineTab();
footer.innerHTML = `<button class="o1e-btn o1e-btn-ghost" data-refresh>刷新</button>`;
footer.querySelector("[data-refresh]").addEventListener("click", loadMine);
} else {
renderCreateTab();
footer.innerHTML = `<button class="o1e-btn o1e-btn-primary" data-submit>创建主体</button>`;
footer.querySelector("[data-submit]").addEventListener("click", submitCreate);
}
}
function closePanel() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
// 释放未提交的本地预览 URL
// 释放未提交的本地预览 URL(视频无 blob 预览)
revokePreview(createFrontal); createRefs.forEach(revokePreview);
document.removeEventListener("keydown", handleKeydown);
dialogRoot?.remove();
dialogRoot = null;
activeNode = null;
}
function handleKeydown(e) { if (e.key === "Escape") closePanel(); }
function openPanel(node) {
injectStyles();
if (dialogRoot) closePanel();
activeNode = node;
currentTab = "mine";
createRefType = "image";
createRefs = []; createFrontal = null; createVideo = null; createTags = [];
pendingForm.name = ""; pendingForm.desc = ""; pendingForm.voiceId = "";
trackFocus(node);
dialogRoot = document.createElement("div");
dialogRoot.id = DIALOG_ID;
dialogRoot.innerHTML = `
<div id="o1key-elem-modal" role="dialog" aria-modal="true">
<div id="o1key-elem-header">
<div id="o1key-elem-title">主体(角色/模特/道具)</div>
<button class="o1e-icon-btn" data-close title="关闭">${iconClose()}</button>
</div>
<div id="o1e-tabs">
<button class="o1e-tab active" data-tab="mine">我的主体</button>
<button class="o1e-tab" data-tab="create">创建主体</button>
</div>
<div id="o1e-body"></div>
<div id="o1e-footer">
<div id="o1e-status"></div>
<div id="o1e-footer-row"></div>
</div>
</div>`;
document.body.appendChild(dialogRoot);
dialogRoot.addEventListener("click", e => { if (e.target === dialogRoot) closePanel(); });
dialogRoot.querySelector("[data-close]").addEventListener("click", closePanel);
dialogRoot.querySelectorAll(".o1e-tab").forEach(b =>
b.addEventListener("click", () => switchTab(b.dataset.tab)));
document.addEventListener("keydown", handleKeydown);
switchTab("mine");
loadMine();
}
// ── 扩展注册:在目标节点上添加常驻「主体」按钮 widget ──────────────────────
app.registerExtension({
name: "o1key.elementPanel",
beforeRegisterNodeDef(nodeType, nodeData) {
if (!TARGET_NODES.includes(nodeData.name)) return;
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated?.apply(this, arguments);
this.addWidget("button", "🎭 主体管理", null, () => openPanel(this), { serialize: false });
setTimeout(() => trackFocus(this), 0);
return r;
};
const origMenu = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (canvasRef, options) {
origMenu?.call(this, canvasRef, options);
options.unshift({ content: "🎭 主体管理", callback: () => openPanel(this) });
};
},
});