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
+13
View File
@@ -0,0 +1,13 @@
# Frontend extension rules
These instructions apply to `web/`.
- ComfyUI auto-loads every JavaScript file below this directory through `WEB_DIRECTORY = "./web"`. A new file is a runtime feature, not passive documentation.
- Keep extensions idempotent: avoid duplicate buttons, listeners, styles, timers, and monkey patches after frontend reloads.
- Reuse ComfyUI's `app` and `api` modules through the existing relative import convention.
- Keep DOM IDs and extension names globally unique with the `o1key` prefix.
- Treat values received from workflows, API responses, filenames, and prompts as untrusted. Prefer `textContent`; sanitize before using `innerHTML`.
- Preserve old workflows through narrowly targeted rules in `js/migrateWorkflow.js`. A migration must be safe to run repeatedly.
- Do not introduce a bundler or external CDN dependency without an explicit architecture decision.
- When changing `js/o1keyImageGenerator.js`, run `node ../tests/test_o1key_image_generator_frontend.mjs`.
- Document new panels, server routes, or workflow migrations in `../docs/architecture.md` and `../docs/development.md`.
+1 -1
View File
@@ -8,7 +8,7 @@ app.registerExtension({
id: "o1key.AssetSave",
name: "资产保存",
tooltip: "持久性保存生图记录",
type: "boolean",
type: "hidden",
defaultValue: true,
},
],
+93
View File
@@ -0,0 +1,93 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPE = "BatchNanoBananaPro";
const FORMAT_WIDGET = "图片输出格式";
const QUALITY_WIDGET = "图片质量";
const LOSSY_FORMATS = new Set(["JPEG", "WebP"]);
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function setQualityVisibility(node) {
const formatWidget = findWidget(node, FORMAT_WIDGET);
const qualityWidget = findWidget(node, QUALITY_WIDGET);
if (!formatWidget || !qualityWidget) return;
const numericQuality = Number(qualityWidget.value);
qualityWidget.value = Number.isInteger(numericQuality)
&& numericQuality >= 1
&& numericQuality <= 100
? numericQuality
: 95;
const hidden = !LOSSY_FORMATS.has(formatWidget.value);
if (qualityWidget.__o1keyImageQualityHidden === hidden) return;
qualityWidget.__o1keyImageQualityHidden = hidden;
qualityWidget.hidden = hidden;
qualityWidget.options ??= {};
qualityWidget.options.hidden = hidden;
if (!("__o1keyOriginalComputeSize" in qualityWidget)) {
qualityWidget.__o1keyOriginalComputeSize = qualityWidget.computeSize;
}
if (hidden) {
qualityWidget.computeSize = () => [0, -4];
} else if (qualityWidget.__o1keyOriginalComputeSize) {
qualityWidget.computeSize = qualityWidget.__o1keyOriginalComputeSize;
} else {
delete qualityWidget.computeSize;
}
node.setDirtyCanvas?.(true, true);
}
function scheduleVisibilityUpdate(node) {
setQualityVisibility(node);
requestAnimationFrame(() => setQualityVisibility(node));
}
function guardNode(node) {
if (node.__o1keyBatchNanoBananaImageQuality) return;
node.__o1keyBatchNanoBananaImageQuality = true;
const bindFormatWidget = () => {
const formatWidget = findWidget(node, FORMAT_WIDGET);
if (!formatWidget || formatWidget.__o1keyImageQualityCallback) return;
formatWidget.__o1keyImageQualityCallback = true;
const originalCallback = formatWidget.callback;
formatWidget.callback = function () {
const result = originalCallback?.apply(this, arguments);
scheduleVisibilityUpdate(node);
return result;
};
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function () {
const result = originalOnConfigure?.apply(this, arguments);
bindFormatWidget();
scheduleVisibilityUpdate(this);
return result;
};
bindFormatWidget();
scheduleVisibilityUpdate(node);
}
app.registerExtension({
name: "o1key.batchNanoBananaImageQuality",
nodeCreated(node) {
if (node.comfyClass === NODE_TYPE) guardNode(node);
},
loadedGraphNode(node) {
if (node.comfyClass === NODE_TYPE) {
guardNode(node);
scheduleVisibilityUpdate(node);
}
},
});
+88
View File
@@ -0,0 +1,88 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set(["BatchNanoBananaPro", "O1keyGPTImageBatch"]);
const PATH_COUNT_WIDGET_NAMES = new Set(["图片路径数量", "图片文件夹数量"]);
const AUTOGROW_INPUT_PATTERN = /^参考图组\.参考图(\d+)$/;
function parsePathCount(value) {
const count = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(count) ? Math.max(1, Math.min(5, count)) : 1;
}
function findPathCountWidget(node) {
return node.widgets?.find((widget) => PATH_COUNT_WIDGET_NAMES.has(widget.name));
}
function updateReferenceInputLabels(node) {
const widget = findPathCountWidget(node);
const pathCount = parsePathCount(widget?.value);
let changed = false;
for (const input of node.inputs ?? []) {
const match = AUTOGROW_INPUT_PATTERN.exec(input?.name ?? "");
if (!match) continue;
const label = `参考图${pathCount + Number(match[1])}`;
if (input.label !== label) {
// 只改画布显示标签,不改稳定的内部端口名,避免工作流连线失效。
input.label = label;
changed = true;
}
}
if (changed) node.setDirtyCanvas?.(true, true);
}
function scheduleReferenceInputLabels(node) {
updateReferenceInputLabels(node);
requestAnimationFrame(() => updateReferenceInputLabels(node));
}
function guardNode(node) {
if (node.__o1keyBatchReferenceImageLabels || node.__o1keyBatchNanoBananaReferenceLabels) return;
node.__o1keyBatchReferenceImageLabels = true;
const bindPathCountWidget = () => {
const widget = findPathCountWidget(node);
if (!widget || widget.__o1keyReferenceLabelCallback) return;
widget.__o1keyReferenceLabelCallback = true;
const originalCallback = widget.callback;
widget.callback = function () {
const result = originalCallback?.apply(this, arguments);
scheduleReferenceInputLabels(node);
return result;
};
};
const originalOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function () {
const result = originalOnConnectionsChange?.apply(this, arguments);
scheduleReferenceInputLabels(this);
return result;
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function () {
const result = originalOnConfigure?.apply(this, arguments);
bindPathCountWidget();
scheduleReferenceInputLabels(this);
return result;
};
bindPathCountWidget();
scheduleReferenceInputLabels(node);
}
app.registerExtension({
name: "o1key.batchReferenceImageLabels",
nodeCreated(node) {
if (NODE_TYPES.has(node.comfyClass)) guardNode(node);
},
loadedGraphNode(node) {
if (NODE_TYPES.has(node.comfyClass)) {
guardNode(node);
scheduleReferenceInputLabels(node);
}
},
});
+280
View File
@@ -0,0 +1,280 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
const STYLE_ID = "o1key-cases-styles";
const DIALOG_ID = "o1key-cases-dialog";
const CASES_API = "/o1key/cases";
let cases = [];
let dialogRoot = null;
const CSS = `
#o1key-cases-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-cases-modal{width:min(720px,calc(100vw - 40px));height:min(560px,calc(100vh - 56px));display:flex;flex-direction:column;overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:var(--comfy-menu-bg,#202020);box-shadow:0 24px 80px rgba(0,0,0,.48)}
#o1key-cases-header{height:52px;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-cases-title{font-size:15px;font-weight:700;color:#eee}
#o1key-cases-actions{display:flex;gap:8px;align-items:center}
.o1c-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:background .15s,border-color .15s,color .15s;font-family:inherit}
.o1c-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
#o1key-cases-list{flex:1;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
#o1key-cases-list::-webkit-scrollbar{width:6px}
#o1key-cases-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
.o1c-item{border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:11px 12px;background:rgba(255,255,255,.03);color:inherit;text-align:left;cursor:pointer;transition:background .12s,border-color .12s;font-family:inherit}
.o1c-item:hover{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.16)}
.o1c-item-title{font-size:13px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1c-item-meta{margin-top:6px;color:#7e7e7e;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#o1key-cases-empty{flex:1;min-height:180px;display:flex;align-items:center;justify-content:center;color:#777;font-size:13px}
#o1key-cases-status{height:30px;padding:0 16px;color:#858585;font-size:12px;line-height:30px;border-top:1px solid rgba(255,255,255,.08);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
#o1key-cases-status[data-kind="error"]{color:#df7777}
#o1key-cases-status[data-kind="success"]{color:#63bf8a}
`;
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 iconRefresh() {
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>`;
}
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" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function setStatus(message, kind = "muted") {
const el = dialogRoot?.querySelector("#o1key-cases-status");
if (!el) return;
el.textContent = message || "";
el.dataset.kind = kind;
}
async function loadCases() {
try {
const resp = await api.fetchApi(CASES_API);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
cases = Array.isArray(data.cases) ? data.cases : [];
return true;
} catch (e) {
console.warn("[o1key cases] failed to load cases", e);
cases = [];
return false;
}
}
function renderCasesList() {
const list = dialogRoot?.querySelector("#o1key-cases-list");
if (!list) return;
if (!cases.length) {
list.innerHTML = `<div id="o1key-cases-empty">暂无案例</div>`;
return;
}
list.innerHTML = cases.map(item => `
<button class="o1c-item" data-filename="${escapeHtml(item.filename)}" title="${escapeHtml(item.title)}">
<div class="o1c-item-title">${escapeHtml(item.title)}</div>
<div class="o1c-item-meta">${escapeHtml(item.filename)}</div>
</button>
`).join("");
list.querySelectorAll(".o1c-item").forEach(btn => {
btn.addEventListener("click", () => loadCaseWorkflow(btn.dataset.filename));
});
}
function unwrapWorkflow(payload) {
const data = payload?.case ?? payload;
if (data?.workflow && typeof data.workflow === "object") return data.workflow;
return data;
}
async function loadCaseWorkflow(filename) {
if (!filename) return;
setStatus("正在加载案例...");
try {
const resp = await api.fetchApi(`/o1key/case?file=${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const payload = await resp.json();
const workflow = unwrapWorkflow(payload);
if (!workflow || typeof workflow !== "object") throw new Error("invalid workflow");
if (typeof app.loadGraphData === "function") {
await app.loadGraphData(workflow);
} else if (app.graph?.configure) {
app.graph.configure(workflow);
app.graph.setDirtyCanvas?.(true, true);
} else {
throw new Error("loadGraphData unavailable");
}
setStatus("案例已加载", "success");
setTimeout(closeCasesDialog, 250);
} catch (e) {
console.warn("[o1key cases] failed to load case", e);
setStatus("案例加载失败", "error");
}
}
async function refreshCases() {
setStatus("正在刷新案例...");
const ok = await loadCases();
renderCasesList();
if (ok) {
setStatus(cases.length ? `已加载 ${cases.length} 个案例` : "暂无案例");
} else {
setStatus("案例加载失败,请重启 ComfyUI 后再试", "error");
}
}
function closeCasesDialog() {
document.removeEventListener("keydown", handleDialogKeydown);
dialogRoot?.remove();
dialogRoot = null;
}
function handleDialogKeydown(event) {
if (event.key === "Escape") closeCasesDialog();
}
async function openCasesDialog() {
injectStyles();
const existing = document.getElementById(DIALOG_ID);
if (existing) {
dialogRoot = existing;
await refreshCases();
return;
}
dialogRoot = document.createElement("div");
dialogRoot.id = DIALOG_ID;
dialogRoot.innerHTML = `
<div id="o1key-cases-modal" role="dialog" aria-modal="true" aria-labelledby="o1key-cases-title">
<div id="o1key-cases-header">
<div id="o1key-cases-title">案例</div>
<div id="o1key-cases-actions">
<button class="o1c-icon-btn" data-o1key-refresh title="刷新" aria-label="刷新">${iconRefresh()}</button>
<button class="o1c-icon-btn" data-o1key-close title="关闭" aria-label="关闭">${iconClose()}</button>
</div>
</div>
<div id="o1key-cases-list"></div>
<div id="o1key-cases-status"></div>
</div>
`;
document.body.appendChild(dialogRoot);
dialogRoot.addEventListener("click", (event) => {
if (event.target === dialogRoot) closeCasesDialog();
});
dialogRoot.querySelector("[data-o1key-close]").addEventListener("click", closeCasesDialog);
dialogRoot.querySelector("[data-o1key-refresh]").addEventListener("click", refreshCases);
document.addEventListener("keydown", handleDialogKeydown);
renderCasesList();
await refreshCases();
dialogRoot.querySelector("[data-o1key-close]")?.focus();
}
function renderCasesPanel(container) {
container.innerHTML = "";
openCasesDialog();
}
function getControlLabel(el) {
return [
el.getAttribute("aria-label"),
el.getAttribute("title"),
el.getAttribute("data-title"),
el.getAttribute("data-label"),
el.textContent,
].filter(Boolean).join(" ").trim().toLowerCase();
}
function bindCaseControlClick(caseControl) {
if (!caseControl || caseControl.dataset.o1keyCasesBound) return;
caseControl.dataset.o1keyCasesBound = "1";
caseControl.addEventListener("click", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
openCasesDialog();
}, true);
}
function placeCasesAfterTemplates() {
const controls = [...document.querySelectorAll(".side-bar-button, [class*='sidebar'] button, .p-togglebutton, [role='tab']")];
const caseControl = controls.find(el => getControlLabel(el).includes("案例"));
const templateControl = controls.find(el => {
const label = getControlLabel(el);
return label.includes("模板") || label.includes("template");
});
bindCaseControlClick(caseControl);
if (!caseControl || !templateControl || !templateControl.parentElement) return false;
if (templateControl.nextElementSibling !== caseControl) {
templateControl.parentElement.insertBefore(caseControl, templateControl.nextElementSibling);
}
return true;
}
function watchSidebarOrder() {
const apply = () => placeCasesAfterTemplates();
setTimeout(apply, 300);
setTimeout(apply, 1200);
// 防抖:避免高频触发,增加防抖时间
let debounceTimer = null;
const debouncedApply = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(apply, 300); // 从 150ms 增加到 300ms
};
// 只监听侧边栏区域,而不是整个 body
const observer = new MutationObserver((mutations) => {
// 只在侧边栏相关变化时才执行
const hasSidebarChange = mutations.some(m => {
const target = m.target;
if (!target || !target.nodeType === 1) return false;
const el = target.classList ? target : target.parentElement;
if (!el) return false;
const cls = el.className || '';
return cls.includes('sidebar') || cls.includes('side-bar') ||
cls.includes('p-togglebutton') || el.getAttribute?.('role') === 'tab';
});
if (hasSidebarChange) debouncedApply();
});
// 尝试只监听侧边栏容器;如果找不到则降级到 body(但使用防抖)
const sidebarContainer = document.querySelector('[class*="sidebar"], .side-bar, [class*="Sidebar"]') || document.body;
observer.observe(sidebarContainer, { childList: true, subtree: true });
}
app.registerExtension({
name: "o1key.casePanel",
async setup() {
await loadCases();
app.extensionManager.registerSidebarTab({
id: "o1key-cases",
title: "案例",
icon: "pi pi-book",
type: "custom",
render: (container) => {
injectStyles();
renderCasesPanel(container);
},
});
watchSidebarOrder();
},
});
+57 -15
View File
@@ -2,10 +2,12 @@ import { app } from "../../../scripts/app.js";
// ─── State ───────────────────────────────────────────────────────────────────
const STORAGE_KEY = "o1key-chat-conversations";
const DEFAULT_MODEL = "gpt-6-sol";
let conversations = [];
let activeConvId = null;
let currentModel = "gpt-5.5";
let currentReasoning = "medium";
let currentModel = DEFAULT_MODEL;
let webSearchEnabled = false;
let currentReasoning = "high";
let isStreaming = false;
let isThinking = false;
let abortController = null;
@@ -16,7 +18,7 @@ let showHistory = false;
const MAX_FILES = 4;
const MAX_FILE_SIZE = 20 * 1024 * 1024;
const ACCEPT_STRING = "image/*,video/*,audio/*,.pdf,.txt,.md,.csv,.json,.doc,.docx";
const ACCEPT_STRING = "image/*,video/*,audio/*,.pdf,.txt,.md,.csv,.json,.doc,.docx,.xlsx,.xlsm";
function classifyFile(mimeType) {
if (mimeType.startsWith("image/")) return "image";
@@ -52,12 +54,13 @@ function truncateFilename(name, maxLen = 20) {
}
const MODELS = [
DEFAULT_MODEL,
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.5",
"gemini-3.5-flash",
"claude-fable-5",
"gemini-3.1-pro-preview",
"deepseek-v4-pro",
"claude-opus-4-7",
"claude-opus-4-6",
"doubao-seed-2.0-pro",
];
@@ -119,6 +122,9 @@ const CSS = `
#o1key-chat-toolbar select:focus{border-color:rgba(255,255,255,.3);background-color:rgba(255,255,255,.1)}
#o1key-chat-toolbar #o1k-reasoning-sel{flex:none;width:auto;padding-right:28px}
#o1key-chat-toolbar select option{background:#2a2a2a;color:#ddd;padding:8px}
#o1key-chat-toolbar .o1k-search-toggle{width:34px;height:34px;display:flex;align-items:center;justify-content:center;flex:none;background:rgba(255,255,255,.05);color:#777;border:1px solid rgba(255,255,255,.1);border-radius:8px;cursor:pointer;transition:all .15s}
#o1key-chat-toolbar .o1k-search-toggle:hover{color:#ccc;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
#o1key-chat-toolbar .o1k-search-toggle.active{color:#7eb8f7;background:rgba(126,184,247,.14);border-color:rgba(126,184,247,.45)}
#o1key-chat-messages{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:2px;scroll-behavior:smooth;min-height:0}
#o1key-chat-messages::-webkit-scrollbar{width:4px}
#o1key-chat-messages::-webkit-scrollbar-track{background:transparent}
@@ -171,6 +177,11 @@ const CSS = `
.o1k-reasoning .o1k-reasoning-body{padding:6px 10px 8px;font-size:12px;color:#777;line-height:1.5;border-top:1px solid rgba(255,255,255,.04);max-height:200px;overflow-y:auto}
.o1k-reasoning .o1k-reasoning-body::-webkit-scrollbar{width:3px}
.o1k-reasoning .o1k-reasoning-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:2px}
.o1k-search-trace{margin:0 0 8px;border:1px solid rgba(126,184,247,.16);border-radius:8px;overflow:hidden;background:rgba(126,184,247,.04)}
.o1k-search-trace summary{padding:6px 10px;font-size:11px;color:#8dbff2;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1k-search-results{padding:4px 10px 8px;border-top:1px solid rgba(126,184,247,.1);display:flex;flex-direction:column;gap:5px}
.o1k-search-results a{font-size:11px;line-height:1.35;color:#8dbff2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1k-search-error{font-size:11px;color:#d49a8f}
#o1key-chat-history{flex:1;overflow-y:auto;padding:8px 14px;display:flex;flex-direction:column;gap:4px;min-height:0}
.o1k-conv-item{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;cursor:pointer;transition:all .12s;border:1px solid transparent}
.o1k-conv-item:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.08)}
@@ -251,7 +262,7 @@ app.registerExtension({
loadConversations();
app.extensionManager.registerSidebarTab({
id: "o1key-chat",
title: "对话",
title: "聊天",
icon: "pi pi-comments",
type: "custom",
render: (container) => {
@@ -272,7 +283,7 @@ function renderChatPanel(container) {
root.id = "o1key-chat-root";
root.innerHTML = `
<div id="o1key-chat-header">
<span class="chat-title">AI 对话</span>
<span class="chat-title">聊天</span>
<div class="hdr-btns">
<button class="chat-hdr-btn" id="o1k-history-btn" title="对话记录">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="9"/></svg>
@@ -285,6 +296,9 @@ function renderChatPanel(container) {
<div id="o1key-chat-toolbar">
<select id="o1k-model-sel">${MODELS.map(m => `<option value="${m}"${m === currentModel ? " selected" : ""}>${m}</option>`).join("")}</select>
<select id="o1k-reasoning-sel"><option value="low"${currentReasoning === "low" ? " selected" : ""}>思考:低</option><option value="medium"${currentReasoning === "medium" ? " selected" : ""}>思考:中</option><option value="high"${currentReasoning === "high" ? " selected" : ""}>思考:高</option></select>
<button class="o1k-search-toggle${webSearchEnabled ? " active" : ""}" id="o1k-web-search" title="联网搜索" aria-label="联网搜索" aria-pressed="${webSearchEnabled}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/></svg>
</button>
</div>
<div id="o1key-chat-messages"></div>
<div id="o1key-chat-history" style="display:none"></div>
@@ -312,6 +326,7 @@ function renderChatPanel(container) {
function bindEvents(root) {
const modelSel = root.querySelector("#o1k-model-sel");
const reasoningSel = root.querySelector("#o1k-reasoning-sel");
const webSearchBtn = root.querySelector("#o1k-web-search");
const newBtn = root.querySelector("#o1k-new-chat");
const histBtn = root.querySelector("#o1k-history-btn");
const input = root.querySelector("#o1k-input");
@@ -321,6 +336,11 @@ function bindEvents(root) {
modelSel.addEventListener("change", () => { currentModel = modelSel.value; });
reasoningSel.addEventListener("change", () => { currentReasoning = reasoningSel.value; });
webSearchBtn.addEventListener("click", () => {
webSearchEnabled = !webSearchEnabled;
webSearchBtn.classList.toggle("active", webSearchEnabled);
webSearchBtn.setAttribute("aria-pressed", String(webSearchEnabled));
});
newBtn.addEventListener("click", startNewConversation);
histBtn.addEventListener("click", toggleHistory);
@@ -976,7 +996,13 @@ async function sendMessage(text, files, rawContent) {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({ model: currentModel, messages: reqMsgs, stream: true, reasoning_effort: currentReasoning }),
body: JSON.stringify({
model: currentModel,
messages: reqMsgs,
stream: true,
reasoning_effort: currentReasoning,
web_search: webSearchEnabled,
}),
});
if (!resp.ok) {
isThinking = false;
@@ -1004,6 +1030,10 @@ async function sendMessage(text, files, rawContent) {
const delta = chunk.choices?.[0]?.delta;
const finish = chunk.choices?.[0]?.finish_reason;
const lastMsg = conv.messages[conv.messages.length-1];
if (chunk.o1key_search) {
lastMsg._search = chunk.o1key_search;
updateLastMessage();
}
if (delta?.reasoning_content) {
lastMsg._reasoning = (lastMsg._reasoning || "") + delta.reasoning_content;
updateLastMessage();
@@ -1084,6 +1114,18 @@ function renderMessages() {
function formatMsgContent(msg, idx, msgs) {
const c = msg.content;
let searchHtml = "";
if (msg.role === "assistant" && msg._search) {
const trace = msg._search;
const results = Array.isArray(trace.results) ? trace.results : [];
const resultHtml = results.map((item, index) =>
`<a href="${escapeHtml(item.url || "")}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(item.url || "")}">[${index + 1}] ${escapeHtml(item.title || item.url || "搜索结果")}</a>`
).join("");
const body = trace.error
? `<span class="o1k-search-error">${escapeHtml(trace.error)}</span>`
: resultHtml;
searchHtml = `<details class="o1k-search-trace"><summary>联网搜索 · ${escapeHtml(trace.query || "")}</summary><div class="o1k-search-results">${body}</div></details>`;
}
let reasoningHtml = "";
if (msg.role === "assistant" && msg._reasoning) {
const isCurrentStreaming = isStreaming && idx === msgs.length - 1;
@@ -1094,17 +1136,17 @@ function formatMsgContent(msg, idx, msgs) {
reasoningHtml = `<details class="o1k-reasoning"${openAttr}><summary>思考过程</summary><div class="o1k-reasoning-body">${reasoningText}</div></details>`;
}
if (typeof c === "string") {
if (msg.role === "assistant" && !isStreaming) return reasoningHtml + renderMd(c);
if (msg.role === "assistant" && !isStreaming) return searchHtml + reasoningHtml + renderMd(c);
if (msg.role === "assistant" && c === "" && idx === msgs.length - 1) {
if (msg._reasoning) {
return reasoningHtml;
return searchHtml + reasoningHtml;
}
if (isThinking) {
return `<div class="o1k-thinking"><div class="o1k-thinking-icon"></div><span class="o1k-thinking-text">思考中...</span></div>`;
return searchHtml + `<div class="o1k-thinking"><div class="o1k-thinking-icon"></div><span class="o1k-thinking-text">思考中...</span></div>`;
}
return `<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>`;
return searchHtml + `<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>`;
}
if (msg.role === "assistant") return reasoningHtml + escapeHtml(c).replace(/\n/g, "<br>");
if (msg.role === "assistant") return searchHtml + reasoningHtml + escapeHtml(c).replace(/\n/g, "<br>");
return escapeHtml(c).replace(/\n/g, "<br>");
}
if (Array.isArray(c)) {
@@ -1198,4 +1240,4 @@ function renderMd(text) {
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
-41
View File
@@ -1,41 +0,0 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "o1key.dotGrid",
async setup() {
function createBlackTile() {
const size = 64;
const c = document.createElement("canvas");
c.width = size;
c.height = size;
const ctx = c.getContext("2d");
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, size, size);
return c;
}
// Hook immediately so the first draw already uses our tile
const orig = LGraphCanvas.prototype.drawBackCanvas;
LGraphCanvas.prototype.drawBackCanvas = function () {
if (!this._pattern || !this._pattern_img) {
const ctx = this.bgcanvas?.getContext("2d");
if (ctx) {
const t = createBlackTile();
this._pattern = ctx.createPattern(t, "repeat");
this._pattern_img = t;
}
}
return orig.apply(this, arguments);
};
// Also apply to current canvas instance if already exists
const canvas = app.canvas;
if (canvas?.bgcanvas) {
const bgCtx = canvas.bgcanvas.getContext("2d");
const tile = createBlackTile();
canvas._pattern = bgCtx.createPattern(tile, "repeat");
canvas._pattern_img = tile;
canvas.draw(true, true);
}
},
});
+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; // 轮询定时器
// 主体标签(对应腾讯云 TagListo_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/MOV3~8秒,16:9 或 9:161080P,≤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) });
};
},
});
+34
View File
@@ -0,0 +1,34 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set(["O1keyGPTImage", "O1keyGPTImageBatch"]);
const BACKGROUND_LABELS = Object.freeze({
auto: "自动",
transparent: "透明",
opaque: "不透明",
});
const INSTALLED_FLAG = "o1keyGptImageBackgroundLabelsInstalled";
function installBackgroundLabels(node) {
if (!NODE_TYPES.has(node?.comfyClass || node?.type)) return;
const widget = node.widgets?.find((item) => item.name === "背景");
if (!widget) return;
widget.options ??= {};
if (widget.options[INSTALLED_FLAG]) return;
const previousGetOptionLabel = widget.options.getOptionLabel;
widget.options.getOptionLabel = (value) => {
const normalized = value == null ? "" : String(value);
return BACKGROUND_LABELS[normalized]
?? previousGetOptionLabel?.(value)
?? normalized;
};
widget.options[INSTALLED_FLAG] = true;
node.setDirtyCanvas?.(true, false);
}
app.registerExtension({
name: "o1key.gptImageBackgroundLabels",
nodeCreated: installBackgroundLabels,
loadedGraphNode: installBackgroundLabels,
});
+60
View File
@@ -0,0 +1,60 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set(["O1keyGPTImage", "O1keyGPTImageBatch"]);
const BASE_QUALITY_OPTIONS = ["高", "中", "低", "自动"];
const GPT_IMAGE_25_QUALITY_OPTIONS = [...BASE_QUALITY_OPTIONS, "超高", "最高"];
const QUALITY_FALLBACK = "自动";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function updateQualityOptions(node) {
const modelWidget = findWidget(node, "模型");
const qualityWidget = findWidget(node, "质量");
if (!modelWidget || !qualityWidget) return;
const supports25Quality =
typeof modelWidget.value === "string" && modelWidget.value.startsWith("gpt-image-2.5-");
const allowedOptions = supports25Quality ? GPT_IMAGE_25_QUALITY_OPTIONS : BASE_QUALITY_OPTIONS;
qualityWidget.options ??= {};
qualityWidget.options.values = [...allowedOptions];
if (!allowedOptions.includes(qualityWidget.value)) {
qualityWidget.value = QUALITY_FALLBACK;
qualityWidget.callback?.(QUALITY_FALLBACK);
}
node.setDirtyCanvas?.(true, true);
}
function guardNode(node) {
if (node.__o1keyGptImageQuality) return;
const modelWidget = findWidget(node, "模型");
const qualityWidget = findWidget(node, "质量");
if (!modelWidget || !qualityWidget) return;
node.__o1keyGptImageQuality = true;
const originalModelCallback = modelWidget.callback;
modelWidget.callback = function () {
const result = originalModelCallback?.apply(this, arguments);
updateQualityOptions(node);
return result;
};
updateQualityOptions(node);
}
app.registerExtension({
name: "o1key.gptImageQuality",
nodeCreated(node) {
if (NODE_TYPES.has(node.comfyClass || node.type)) guardNode(node);
},
loadedGraphNode(node) {
if (!NODE_TYPES.has(node.comfyClass || node.type)) return;
guardNode(node);
updateQualityOptions(node);
},
});
+18 -213
View File
@@ -3,219 +3,24 @@ import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "o1key.hideSidebarItems",
async setup() {
const hide = () => {
// 隐藏侧边栏的"说明"、"应用"、"模型"、"节点"、"模板"按钮
const hiddenLabels = ["说明", "帮助", "help", "应用", "apps", "模型", "models", "节点", "nodes", "模板", "templates", "template"];
document.querySelectorAll(".side-bar-button, [class*='sidebar'] button, .p-togglebutton").forEach(btn => {
const label = [
btn.getAttribute("aria-label"),
btn.getAttribute("title"),
btn.getAttribute("data-title"),
btn.getAttribute("data-label"),
btn.textContent,
].filter(Boolean).join(" ").toLowerCase();
if (hiddenLabels.some(k => label.includes(k))) {
btn.style.display = "none";
}
});
// 隐藏左上角下拉菜单中的"帮助"项
document.querySelectorAll(".p-menuitem, .p-menu-item, [class*='menu'] li, [class*='Menu'] li").forEach(item => {
const text = item.textContent || "";
if (text.trim() === "帮助" || text.trim() === "Help") {
item.style.display = "none";
}
});
// 隐藏登录/注册弹框(Google/Github 登录对话框)
document.querySelectorAll("[class*='dialog'], [class*='Dialog'], [class*='modal'], [class*='Modal']").forEach(dialog => {
const text = dialog.textContent || "";
if ((text.includes("Google") || text.includes("Github")) &&
(text.includes("登录") || text.includes("注册"))) {
dialog.style.display = "none";
const mask = dialog.previousElementSibling;
if (mask && mask.className && mask.className.includes("mask")) {
mask.style.display = "none";
}
}
});
// 在右侧内容区隐藏"登录/注册"按钮并注入 API Key
injectApiKeyPanel();
};
if (document.querySelector("#o1key-hidden-ui-styles")) return;
async function injectApiKeyPanel() {
// 找到右侧内容区中包含"我的用户设置"的区域
let contentArea = null;
document.querySelectorAll("h1, h2, h3, h4, span, div").forEach(el => {
const t = (el.textContent || "").trim();
if (t === "我的用户设置" || t === "My User Settings") {
contentArea = el.closest("div");
}
});
if (!contentArea) return;
// 隐藏"登录/注册"按钮和"登录您的账户"文字
contentArea.querySelectorAll("button, a, span, p, div").forEach(el => {
const t = (el.textContent || "").trim();
if (t.includes("登录") || t.includes("注册") || t === "Sign In" || t === "Sign Up" || t.includes("登录您的账户") || t.includes("Log in")) {
if (el.tagName === "BUTTON" || el.tagName === "A" || t.includes("登录您的账户")) {
el.style.display = "none";
}
}
});
// 检查是否已注入(DOM 中已存在则跳过)
if (document.querySelector("#o1key-apikey-box")) return;
// 创建 API Key 输入区域
const box = document.createElement("div");
box.id = "o1key-apikey-box";
box.style.cssText = "margin-top:24px;padding:20px;border:1px solid #444;border-radius:8px;background:#1e1e1e;";
box.innerHTML = `
<div style="font-weight:bold;font-size:15px;margin-bottom:6px;color:#eee;">O1Key API 密钥</div>
<div style="font-size:12px;color:#999;margin-bottom:14px;">输入您的 API 密钥,测试通过后方可保存</div>
<div style="display:flex;gap:8px;align-items:center;">
<input id="o1key-apikey-input" type="text" placeholder="请输入 API 密钥"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-form-type="other" data-lpignore="true" name="o1key-key-field"
style="flex:1;padding:8px 12px;border:1px solid #555;border-radius:4px;background:#111;color:#eee;font-size:13px;" />
<button id="o1key-apikey-test"
style="padding:8px 14px;border:none;border-radius:4px;background:#47a;color:#fff;cursor:pointer;font-size:13px;white-space:nowrap;">测试令牌</button>
<button id="o1key-apikey-save" disabled
style="padding:8px 14px;border:none;border-radius:4px;background:#555;color:#999;cursor:not-allowed;font-size:13px;white-space:nowrap;">保存</button>
<button id="o1key-apikey-clear"
style="padding:8px 14px;border:none;border-radius:4px;background:#a44;color:#fff;cursor:pointer;font-size:13px;white-space:nowrap;">清空密钥</button>
</div>
<div id="o1key-apikey-status" style="margin-top:10px;font-size:12px;color:#999;"></div>
`;
contentArea.appendChild(box);
// 加载当前状态
try {
const resp = await fetch("/o1key/api_key");
const data = await resp.json();
const status = box.querySelector("#o1key-apikey-status");
if (data.has_key) {
status.textContent = "当前密钥: " + data.masked;
status.style.color = "#3b8";
} else {
status.textContent = "尚未配置 API 密钥";
status.style.color = "#a84";
}
} catch (e) {}
const saveBtn = box.querySelector("#o1key-apikey-save");
const testBtn = box.querySelector("#o1key-apikey-test");
let testPassed = false;
// 输入变化时重置测试状态
box.querySelector("#o1key-apikey-input").addEventListener("input", () => {
testPassed = false;
saveBtn.disabled = true;
saveBtn.style.background = "#555";
saveBtn.style.color = "#999";
saveBtn.style.cursor = "not-allowed";
});
// 测试令牌按钮
testBtn.addEventListener("click", async () => {
const input = box.querySelector("#o1key-apikey-input");
const status = box.querySelector("#o1key-apikey-status");
const key = input.value.trim();
if (!key) { status.textContent = "请输入密钥"; status.style.color = "#a44"; return; }
testBtn.disabled = true;
testBtn.textContent = "验证中...";
status.textContent = "正在验证密钥...";
status.style.color = "#999";
try {
const resp = await fetch("/o1key/test_key", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: key})
});
const data = await resp.json();
if (data.valid) {
testPassed = true;
status.textContent = "验证通过,可以保存";
status.style.color = "#3b8";
saveBtn.disabled = false;
saveBtn.style.background = "#3b8";
saveBtn.style.color = "#fff";
saveBtn.style.cursor = "pointer";
} else {
testPassed = false;
status.textContent = data.error || "验证失败";
status.style.color = "#a44";
}
} catch (e) {
status.textContent = "网络错误";
status.style.color = "#a44";
}
testBtn.disabled = false;
testBtn.textContent = "测试令牌";
});
// 保存按钮(仅测试通过后可用)
saveBtn.addEventListener("click", async () => {
if (!testPassed) return;
const input = box.querySelector("#o1key-apikey-input");
const status = box.querySelector("#o1key-apikey-status");
const key = input.value.trim();
try {
const resp = await fetch("/o1key/api_key", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: key})
});
const data = await resp.json();
if (data.success) {
status.textContent = "密钥已保存";
status.style.color = "#3b8";
input.value = "";
testPassed = false;
saveBtn.disabled = true;
saveBtn.style.background = "#555";
saveBtn.style.color = "#999";
saveBtn.style.cursor = "not-allowed";
} else {
status.textContent = data.error || "保存失败";
status.style.color = "#a44";
}
} catch (e) {
status.textContent = "网络错误";
status.style.color = "#a44";
}
});
// 清空密钥按钮
const clearBtn = box.querySelector("#o1key-apikey-clear");
clearBtn.addEventListener("click", async () => {
if (!confirm("确定要清空 API 密钥吗?")) return;
const status = box.querySelector("#o1key-apikey-status");
try {
const resp = await fetch("/o1key/api_key", { method: "DELETE" });
const data = await resp.json();
if (data.success) {
status.textContent = "API 密钥已清空";
status.style.color = "#a84";
box.querySelector("#o1key-apikey-input").value = "";
testPassed = false;
saveBtn.disabled = true;
saveBtn.style.background = "#555";
saveBtn.style.color = "#999";
saveBtn.style.cursor = "not-allowed";
} else {
status.textContent = data.error || "清空失败";
status.style.color = "#a44";
}
} catch (e) {
status.textContent = "网络错误";
status.style.color = "#a44";
}
});
}
const observer = new MutationObserver(hide);
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(hide, 1000);
setTimeout(hide, 3000);
const style = document.createElement("style");
style.id = "o1key-hidden-ui-styles";
style.textContent = `
.templates-tab-button,
.apps-tab-button,
.model-library-tab-button,
.node-library-tab-button,
.o1key-cases-tab-button,
.comfy-help-center-btn,
.comfy-command-menu li:has([class~="icon-[comfy--template]"]),
.comfy-command-menu li:has(.mdi-help-circle-outline),
.p-dialog-mask:has(.pi-google):has(.pi-github),
[role="dialog"]:has(.pi-google):has(.pi-github) {
display: none !important;
}
`;
document.head.appendChild(style);
},
});
+59
View File
@@ -0,0 +1,59 @@
import { app } from "../../../scripts/app.js";
// ── DynamicCombo 节点加载工作流后被自动缩小的修复 ─────────────────────────────
//
// 现象:把含 DynamicCombo 的节点(如「K3 图生视频 首尾帧 多分镜」)手动拉大,
// 切走再切回工作流,节点高度会被自动压扁。
//
// 根因(ComfyUI 核心行为,非本仓库代码):
// core/graph/widgets/dynamicWidgets.ts 的 updateWidgets() 在重建子控件后执行
// node.size[1] = node.computeSize([...node.size])[1]
// 把高度强制压回「最小内容高」。而 LGraphNode.configure() 的顺序是:
// 1) 先把 this.size 还原成保存的(被用户拉大的)尺寸
// 2) 再还原 widgets_values —— 给 DynamicCombo 赋值触发上面那行,高度被压扁
// 3) 最后才触发 onConfigure
// 所以只压高度、宽度不变,表现为节点「自动缩小」。
//
// 修复:在 onConfigure(此时压扁已发生,但保存尺寸仍在 info.size 里)把尺寸还原回去。
// 仅当当前尺寸比保存值更小时才还原,避免覆盖其它合理布局。
function hasDynamicCombo(nodeData) {
try {
return JSON.stringify(nodeData?.input ?? {}).includes("COMFY_DYNAMICCOMBO_V3");
} catch (e) {
return false;
}
}
app.registerExtension({
name: "o1key.keepNodeSize",
beforeRegisterNodeDef(nodeType, nodeData) {
if (!hasDynamicCombo(nodeData)) return;
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
const saved = info?.size;
if (!saved) return;
const savedW = Number(saved[0]) || Number(saved["0"]) || 0;
const savedH = Number(saved[1]) || Number(saved["1"]) || 0;
if (savedW <= 0 && savedH <= 0) return;
const restore = () => {
const w = Math.max(this.size[0], savedW);
const h = Math.max(this.size[1], savedH);
// 仅在被压小时还原,避免无谓重排
if (this.size[0] < w - 0.5 || this.size[1] < h - 0.5) {
this.setSize([w, h]);
this.setDirtyCanvas?.(true, true);
}
};
restore();
// 兜底:个别布局在下一帧才结算,再还原一次(已加守卫,幂等)
requestAnimationFrame(restore);
};
},
});
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPE = "MiniMaxH3Video";
const MODEL_H3 = "MiniMax-H3";
const MODEL_MAX = "MiniMax-H3-MAX";
const MODE_TEXT = "文生视频";
const MODE_REFERENCE = "参考素材生视频";
const RESOLUTIONS = {
[MODEL_H3]: ["2K", "768P"],
[MODEL_MAX]: ["768P", "480P"],
};
const MIN_DURATION = {
[MODEL_H3]: 4,
[MODEL_MAX]: 5,
};
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function notify(detail) {
app.extensionManager?.toast?.add({
severity: "warn",
summary: "MiniMax H3 参数已调整",
detail,
life: 4000,
});
}
function setWidgetValue(widget, value) {
if (!widget || widget.value === value) return;
widget.value = value;
widget.callback?.(value);
}
function updateParameters(node, silent = false) {
const modelWidget = findWidget(node, "模型");
const modeWidget = findWidget(node, "生成模式");
const resolutionWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !modeWidget || !resolutionWidget || !durationWidget) return;
const model = modelWidget.value === MODEL_MAX ? MODEL_MAX : MODEL_H3;
const allowedResolutions = RESOLUTIONS[model];
resolutionWidget.options ??= {};
resolutionWidget.options.values = [...allowedResolutions];
durationWidget.options ??= {};
durationWidget.options.min = MIN_DURATION[model];
durationWidget.options.max = 15;
if (!allowedResolutions.includes(resolutionWidget.value)) {
setWidgetValue(resolutionWidget, "768P");
if (!silent) notify(`${model} 不支持原分辨率,已切换为 768P。`);
}
const duration = Number(durationWidget.value);
if (Number.isFinite(duration) && duration < MIN_DURATION[model]) {
setWidgetValue(durationWidget, MIN_DURATION[model]);
if (!silent) notify(`${model} 最短时长为 ${MIN_DURATION[model]} 秒。`);
}
if (model === MODEL_MAX && modeWidget.value === MODE_REFERENCE) {
setWidgetValue(modeWidget, MODE_TEXT);
if (!silent) notify("MiniMax-H3-MAX 不支持参考素材模式,已切换为文生视频。");
}
node.setDirtyCanvas?.(true, true);
}
function guardNode(node) {
if (node.__o1keyMiniMaxH3ParameterGuard) return;
const modelWidget = findWidget(node, "模型");
const modeWidget = findWidget(node, "生成模式");
const resolutionWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !modeWidget || !resolutionWidget || !durationWidget) return;
node.__o1keyMiniMaxH3ParameterGuard = true;
const originalModelCallback = modelWidget.callback;
modelWidget.callback = function () {
const result = originalModelCallback?.apply(this, arguments);
updateParameters(node);
return result;
};
const originalModeCallback = modeWidget.callback;
modeWidget.callback = function (value) {
if (modelWidget.value === MODEL_MAX && value === MODE_REFERENCE) {
modeWidget.value = MODE_TEXT;
notify("MiniMax-H3-MAX 不支持参考素材模式。");
node.setDirtyCanvas?.(true, true);
return originalModeCallback?.call(this, MODE_TEXT);
}
return originalModeCallback?.apply(this, arguments);
};
const originalResolutionCallback = resolutionWidget.callback;
resolutionWidget.callback = function (value) {
const model = modelWidget.value === MODEL_MAX ? MODEL_MAX : MODEL_H3;
if (!RESOLUTIONS[model].includes(value)) {
resolutionWidget.value = "768P";
notify(`${model} 不支持 ${value},已切换为 768P。`);
node.setDirtyCanvas?.(true, true);
return originalResolutionCallback?.call(this, "768P");
}
return originalResolutionCallback?.apply(this, arguments);
};
const originalDurationCallback = durationWidget.callback;
durationWidget.callback = function (value) {
const model = modelWidget.value === MODEL_MAX ? MODEL_MAX : MODEL_H3;
const minimum = MIN_DURATION[model];
const duration = Number(value);
if (Number.isFinite(duration) && duration < minimum) {
durationWidget.value = minimum;
notify(`${model} 最短时长为 ${minimum} 秒。`);
node.setDirtyCanvas?.(true, true);
return originalDurationCallback?.call(this, minimum);
}
return originalDurationCallback?.apply(this, arguments);
};
updateParameters(node, true);
}
app.registerExtension({
name: "o1key.minimaxH3ParameterGuard",
nodeCreated(node) {
if ((node.comfyClass || node.type) === NODE_TYPE) guardNode(node);
},
loadedGraphNode(node) {
if ((node.comfyClass || node.type) !== NODE_TYPE) return;
guardNode(node);
updateParameters(node, true);
},
});
+36
View File
@@ -0,0 +1,36 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set([
"NanoBanana",
"BatchNanoBananaPro",
"O1keyGPTImage",
"O1keyGPTImageBatch",
]);
const ROUTE_LABELS = Object.freeze({
"畅速": "特价",
"直连": "优质",
"专线": "企业",
});
const INSTALLED_FLAG = "o1keyNanoBananaRouteLabelsInstalled";
function installRouteLabels(node) {
if (!NODE_TYPES.has(node?.comfyClass)) return;
const widget = node.widgets?.find(item => item.name === "模型线路");
if (!widget?.options || widget.options[INSTALLED_FLAG]) return;
const previousGetOptionLabel = widget.options.getOptionLabel;
widget.options.getOptionLabel = (value) => {
const normalized = value == null ? "" : String(value);
if (ROUTE_LABELS[normalized]) return ROUTE_LABELS[normalized];
return previousGetOptionLabel?.(value) ?? normalized;
};
widget.options[INSTALLED_FLAG] = true;
node.setDirtyCanvas?.(true, false);
}
app.registerExtension({
name: "o1key.nanoBananaRouteLabels",
nodeCreated: installRouteLabels,
loadedGraphNode: installRouteLabels,
});
+88
View File
@@ -0,0 +1,88 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set(["NanoBanana", "BatchNanoBananaPro"]);
const MODEL_WIDGET = "模型";
const THINKING_WIDGET = "思考等级";
const SUPPORTED_MODEL = "Nano Banana 2";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function setThinkingLevelVisibility(node) {
const modelWidget = findWidget(node, MODEL_WIDGET);
const thinkingWidget = findWidget(node, THINKING_WIDGET);
if (!modelWidget || !thinkingWidget) return;
const hidden = modelWidget.value !== SUPPORTED_MODEL;
if (thinkingWidget.__o1keyThinkingHidden === hidden) return;
thinkingWidget.__o1keyThinkingHidden = hidden;
// Vue 节点使用 options.hidden;旧版 LiteGraph 使用 hidden/computeSize。
// 两种状态同时更新,确保隐藏后既不绘制,也不留下空白行。
thinkingWidget.hidden = hidden;
thinkingWidget.options ??= {};
thinkingWidget.options.hidden = hidden;
if (!("__o1keyOriginalComputeSize" in thinkingWidget)) {
thinkingWidget.__o1keyOriginalComputeSize = thinkingWidget.computeSize;
}
if (hidden) {
thinkingWidget.computeSize = () => [0, -4];
} else if (thinkingWidget.__o1keyOriginalComputeSize) {
thinkingWidget.computeSize = thinkingWidget.__o1keyOriginalComputeSize;
} else {
delete thinkingWidget.computeSize;
}
node.setDirtyCanvas?.(true, true);
}
function scheduleVisibilityUpdate(node) {
setThinkingLevelVisibility(node);
requestAnimationFrame(() => setThinkingLevelVisibility(node));
}
function guardNode(node) {
if (node.__o1keyNanoBananaThinkingLevel) return;
node.__o1keyNanoBananaThinkingLevel = true;
const bindModelWidget = () => {
const modelWidget = findWidget(node, MODEL_WIDGET);
if (!modelWidget || modelWidget.__o1keyThinkingLevelCallback) return;
modelWidget.__o1keyThinkingLevelCallback = true;
const originalCallback = modelWidget.callback;
modelWidget.callback = function () {
const result = originalCallback?.apply(this, arguments);
scheduleVisibilityUpdate(node);
return result;
};
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function () {
const result = originalOnConfigure?.apply(this, arguments);
bindModelWidget();
scheduleVisibilityUpdate(this);
return result;
};
bindModelWidget();
scheduleVisibilityUpdate(node);
}
app.registerExtension({
name: "o1key.nanoBananaThinkingLevel",
nodeCreated(node) {
if (NODE_TYPES.has(node.comfyClass)) guardNode(node);
},
loadedGraphNode(node) {
if (NODE_TYPES.has(node.comfyClass)) {
guardNode(node);
scheduleVisibilityUpdate(node);
}
},
});
+416
View File
@@ -0,0 +1,416 @@
import { app } from "../../../scripts/app.js";
const DIALOG_ID = "o1key-api-settings-dialog";
const STYLE_ID = "o1key-api-settings-styles";
const RESTART_STATUS_URL = "/o1key/restart/status";
const RESTART_TIMEOUT_MS = 120_000;
let restartInProgress = false;
function ensureStyles() {
if (document.querySelector(`#${STYLE_ID}`)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${DIALOG_ID} {
width: min(520px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--border-color, #444);
border-radius: 12px;
color: var(--fg-color, #eee);
background: var(--comfy-menu-bg, #202020);
box-shadow: 0 20px 60px rgba(0, 0, 0, .45);
}
#${DIALOG_ID}::backdrop { background: rgba(0, 0, 0, .58); }
#${DIALOG_ID} .o1key-api-form { padding: 22px; }
#${DIALOG_ID} .o1key-api-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
#${DIALOG_ID} h2 { margin: 0; font-size: 18px; }
#${DIALOG_ID} .o1key-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
color: inherit;
background: transparent;
cursor: pointer;
}
#${DIALOG_ID} .o1key-close:hover { background: rgba(127, 127, 127, .18); }
#${DIALOG_ID} label {
display: block;
margin: 14px 0 6px;
font-size: 13px;
color: var(--fg-color, #ddd);
}
#${DIALOG_ID} input,
#${DIALOG_ID} select {
box-sizing: border-box;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--border-color, #555);
border-radius: 7px;
color: inherit;
background: var(--comfy-input-bg, #111);
}
#${DIALOG_ID} .o1key-hint,
#${DIALOG_ID} .o1key-status { margin-top: 7px; font-size: 12px; color: #aaa; }
#${DIALOG_ID} .o1key-status[data-kind="success"] { color: #55c98b; }
#${DIALOG_ID} .o1key-status[data-kind="error"] { color: #ef7777; }
#${DIALOG_ID} .o1key-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
#${DIALOG_ID} .o1key-actions button {
min-height: 34px;
padding: 7px 14px;
border: 1px solid var(--border-color, #555);
border-radius: 7px;
color: var(--fg-color, #eee);
background: var(--comfy-input-bg, #292929);
cursor: pointer;
}
#${DIALOG_ID} .o1key-actions button:hover { filter: brightness(1.12); }
#${DIALOG_ID} .o1key-primary { border-color: #2f8d64; background: #287653; }
#${DIALOG_ID} .o1key-danger { margin-right: auto; border-color: #8b4444; background: #703838; }
#${DIALOG_ID} button:disabled { cursor: wait; opacity: .6; }
`;
document.head.appendChild(style);
}
function setStatus(dialog, message, kind = "info") {
const status = dialog.querySelector(".o1key-status");
status.textContent = message;
status.dataset.kind = kind;
}
async function readJson(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
return data;
}
function delay(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async function fetchWithTimeout(url, options = {}, timeout = 2_500) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, {
...options,
cache: "no-store",
signal: controller.signal,
});
} finally {
window.clearTimeout(timer);
}
}
async function getRestartStatus() {
const separator = RESTART_STATUS_URL.includes("?") ? "&" : "?";
const response = await fetchWithTimeout(
`${RESTART_STATUS_URL}${separator}_=${Date.now()}`
);
return readJson(response);
}
function setRestartButtonState(button, busy) {
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
button.setAttribute("aria-label", busy ? "ComfyUI 正在重启" : "重启");
button.setAttribute("title", busy ? "ComfyUI 正在重启" : "重启");
const icon = button.querySelector(".side-bar-button-icon");
if (icon) {
icon.className = busy
? "pi pi-spinner pi-spin side-bar-button-icon"
: "pi pi-refresh side-bar-button-icon";
}
const label = button.querySelector(".side-bar-button-label");
if (label) label.textContent = busy ? "重启中" : "重启";
}
async function waitForRestart(oldBootId) {
const deadline = Date.now() + RESTART_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const status = await getRestartStatus();
if (status.ready && status.boot_id && status.boot_id !== oldBootId) {
const systemStatus = await fetchWithTimeout(
`/api/system_stats?_=${Date.now()}`
);
if (systemStatus.ok) return;
}
} catch {
// 旧进程退出到新进程监听端口之间,接口暂时不可用是正常状态。
}
await delay(1_000);
}
throw new Error("等待 ComfyUI 重启超时,请查看终端中的错误信息。");
}
function reloadAfterRestart() {
const url = new URL(window.location.href);
url.searchParams.set("o1key_restart", Date.now().toString());
window.location.replace(url.toString());
}
async function restartComfyUI(event) {
if (restartInProgress) return;
if (!window.confirm("确定要重启 ComfyUI 吗?当前正在执行的任务会被中断。")) return;
const button = event.currentTarget;
restartInProgress = true;
setRestartButtonState(button, true);
try {
let currentStatus = null;
try {
currentStatus = await getRestartStatus();
} catch {
// POST 响应还会返回旧进程的 boot_id,因此预检失败不阻断重启。
}
const response = await fetchWithTimeout("/o1key/restart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
}, 5_000);
const result = await readJson(response);
const oldBootId = result.boot_id || currentStatus?.boot_id;
if (!oldBootId) throw new Error("无法确认当前 ComfyUI 进程标识。");
await waitForRestart(oldBootId);
reloadAfterRestart();
} catch (error) {
restartInProgress = false;
setRestartButtonState(button, false);
window.alert(error?.message || "ComfyUI 重启失败,请查看终端中的错误信息。");
}
}
function createDialog() {
const dialog = document.createElement("dialog");
dialog.id = DIALOG_ID;
dialog.innerHTML = `
<form class="o1key-api-form">
<div class="o1key-api-header">
<h2>令牌管理</h2>
<button class="o1key-close" type="button" aria-label="关闭" title="关闭">✕</button>
</div>
<label for="o1key-api-key-input">API Key</label>
<input id="o1key-api-key-input" type="password" autocomplete="new-password"
placeholder="留空则保留当前 API Key" spellcheck="false" />
<div class="o1key-hint o1key-key-hint">正在读取当前配置…</div>
<label for="o1key-network-route-select">网络线路</label>
<select id="o1key-network-route-select"></select>
<div class="o1key-hint">保存后,所有 O1Key 节点从下一次执行开始使用新配置,无需重启。</div>
<div class="o1key-status" role="status" aria-live="polite"></div>
<div class="o1key-actions">
<button class="o1key-danger" type="button" data-action="clear">清除 Key</button>
<button type="button" data-action="test">测试连接</button>
<button class="o1key-primary" type="button" data-action="save">保存并立即生效</button>
</div>
</form>
`;
dialog.querySelector("form").addEventListener("submit", (event) => event.preventDefault());
dialog.querySelector(".o1key-close").addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
const runAction = async (button, action) => {
const buttons = dialog.querySelectorAll("button[data-action]");
buttons.forEach((item) => { item.disabled = true; });
try {
await action();
} catch (error) {
setStatus(dialog, error.message || "操作失败", "error");
} finally {
buttons.forEach((item) => { item.disabled = false; });
button.focus();
}
};
dialog.querySelector('[data-action="save"]').addEventListener("click", (event) => {
runAction(event.currentTarget, async () => {
setStatus(dialog, "正在保存…");
const apiKey = dialog.querySelector("#o1key-api-key-input").value.trim();
const networkRoute = dialog.querySelector("#o1key-network-route-select").value;
const data = await readJson(await fetch("/o1key/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey || undefined, network_route: networkRoute }),
}));
dialog.querySelector("#o1key-api-key-input").value = "";
dialog.querySelector(".o1key-key-hint").textContent = data.has_key
? `当前 Key${data.masked}`
: "当前未配置 API Key";
setStatus(dialog, "已保存,下一次执行立即使用新配置。", "success");
});
});
dialog.querySelector('[data-action="test"]').addEventListener("click", (event) => {
runAction(event.currentTarget, async () => {
setStatus(dialog, "正在测试连接…");
const apiKey = dialog.querySelector("#o1key-api-key-input").value.trim();
const networkRoute = dialog.querySelector("#o1key-network-route-select").value;
const data = await readJson(await fetch("/o1key/test_key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey || undefined, network_route: networkRoute }),
}));
if (!data.valid) throw new Error(data.error || "连接测试失败");
setStatus(dialog, "连接正常。测试不会自动保存,请按“保存并立即生效”。", "success");
});
});
dialog.querySelector('[data-action="clear"]').addEventListener("click", (event) => {
if (!window.confirm("确定清除已保存的 O1Key API Key 吗?")) return;
runAction(event.currentTarget, async () => {
await readJson(await fetch("/o1key/api_key", { method: "DELETE" }));
dialog.querySelector("#o1key-api-key-input").value = "";
dialog.querySelector(".o1key-key-hint").textContent = "当前未配置 API Key";
setStatus(dialog, "API Key 已清除。", "success");
});
});
document.body.appendChild(dialog);
return dialog;
}
async function refreshDialog(dialog) {
setStatus(dialog, "正在读取配置…");
const data = await readJson(await fetch("/o1key/api_key", { cache: "no-store" }));
const select = dialog.querySelector("#o1key-network-route-select");
select.replaceChildren();
for (const route of data.network_route_options || []) {
const option = document.createElement("option");
option.value = route;
option.textContent = route;
select.appendChild(option);
}
select.value = data.network_route || "全球加速";
dialog.querySelector(".o1key-key-hint").textContent = data.has_key
? `当前 Key${data.masked}`
: "当前未配置 API Key";
setStatus(dialog, "");
}
async function openApiSettings() {
ensureStyles();
const dialog = document.querySelector(`#${DIALOG_ID}`) || createDialog();
if (!dialog.open) dialog.showModal();
try {
await refreshDialog(dialog);
} catch (error) {
setStatus(dialog, error.message || "读取配置失败", "error");
}
}
function createSidebarButton(sourceButton, { id, className, iconClass, label, onClick }) {
const button = sourceButton.cloneNode(true);
button.id = id;
button.type = "button";
button.classList.remove("side-bar-button-selected", "o1key-token-manager-button", "o1key-restart-button");
button.classList.add(className);
button.setAttribute("aria-label", label);
button.setAttribute("title", label);
button.removeAttribute("aria-pressed");
const icon = button.querySelector(".side-bar-button-icon");
if (icon) icon.className = `${iconClass} side-bar-button-icon`;
const buttonLabel = button.querySelector(".side-bar-button-label");
if (buttonLabel) buttonLabel.textContent = label;
button.addEventListener("click", onClick);
return button;
}
function mountSidebarButtons() {
let tokenButton = document.querySelector("#o1key-token-manager-button");
const consoleIcon = document.querySelector(
'.side-tool-bar-container [class~="icon-[ph--terminal-bold]"]'
);
const consoleButton = consoleIcon?.closest("button");
if (!consoleButton) return false;
if (!tokenButton) {
tokenButton = createSidebarButton(consoleButton, {
id: "o1key-token-manager-button",
className: "o1key-token-manager-button",
iconClass: "pi pi-key",
label: "令牌管理",
onClick: openApiSettings,
});
consoleButton.before(tokenButton);
}
if (!document.querySelector("#o1key-restart-button")) {
const restartButton = createSidebarButton(tokenButton, {
id: "o1key-restart-button",
className: "o1key-restart-button",
iconClass: "pi pi-refresh",
label: "重启",
onClick: restartComfyUI,
});
tokenButton.after(restartButton);
}
return true;
}
function mountSidebarButtonsWhenReady() {
if (mountSidebarButtons()) return;
// ComfyUI mounts the Vue sidebar after extension setup on some versions.
// Observe only during that initial mount and disconnect immediately once
// the button has been inserted. This is event-driven, not periodic polling.
const observer = new MutationObserver(() => {
if (mountSidebarButtons()) observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
}
app.registerExtension({
name: "o1key.apiSettings",
commands: [
{
id: "o1key.OpenApiSettings",
icon: "pi pi-key",
label: "令牌管理",
function: openApiSettings,
},
],
setup() {
ensureStyles();
// 重启完成后的地址参数只用于绕过浏览器缓存,进入新页面后立即清理。
const pageUrl = new URL(window.location.href);
if (pageUrl.searchParams.has("o1key_restart")) {
pageUrl.searchParams.delete("o1key_restart");
window.history.replaceState(window.history.state, "", pageUrl.toString());
}
mountSidebarButtonsWhenReady();
},
});
File diff suppressed because it is too large Load Diff
+944
View File
@@ -0,0 +1,944 @@
const EDITOR_ID = "o1key-reference-image-editor";
const EDITOR_STYLE_ID = "o1key-reference-image-editor-styles";
const MIN_CROP_SIZE = 8;
export const REFERENCE_EDITOR_RATIOS = Object.freeze([
{ key: "free", label: "自由", ratio: null },
{ key: "original", label: "原图", ratio: "original" },
{ key: "1:1", label: "1:1", ratio: 1 },
{ key: "4:3", label: "4:3", ratio: 4 / 3 },
{ key: "3:4", label: "3:4", ratio: 3 / 4 },
{ key: "3:2", label: "3:2", ratio: 3 / 2 },
{ key: "2:3", label: "2:3", ratio: 2 / 3 },
{ key: "16:9", label: "16:9", ratio: 16 / 9 },
{ key: "9:16", label: "9:16", ratio: 9 / 16 },
]);
const EDITOR_CSS = `
#${EDITOR_ID}{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;padding:20px;box-sizing:border-box;
overflow:hidden;background:rgba(7,8,10,.88);color:#eceef2;font:12px/1.45 Inter,"Microsoft YaHei UI","Microsoft YaHei",system-ui,sans-serif;}
#${EDITOR_ID} *,#${EDITOR_ID} *::before,#${EDITOR_ID} *::after{box-sizing:border-box;}
#${EDITOR_ID} [hidden]{display:none!important;}
.o1key-reference-editor-dialog{width:min(1180px,96vw);height:min(850px,94vh);min-width:640px;min-height:500px;display:grid;
grid-template-rows:52px minmax(0,1fr) 58px;overflow:hidden;border:1px solid rgba(255,255,255,.16);border-radius:14px;
background:#202124;box-shadow:0 28px 90px rgba(0,0,0,.65);}
.o1key-reference-editor-header{display:flex;align-items:center;gap:12px;padding:0 16px;border-bottom:1px solid rgba(255,255,255,.1);}
.o1key-reference-editor-header strong{font-size:15px;}.o1key-reference-editor-filename{min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;color:#96999f;}.o1key-reference-editor-close{margin-left:auto;width:32px;height:32px;border:0;border-radius:8px;
background:#303238;color:#d8d9dc;cursor:pointer;font:22px/1 system-ui,sans-serif;}.o1key-reference-editor-close:hover{background:#3b3d43;color:#fff;}
.o1key-reference-editor-body{min-width:0;min-height:0;display:grid;grid-template-columns:184px minmax(0,1fr);overflow:hidden;}
.o1key-reference-editor-sidebar{min-width:0;min-height:0;padding:14px 12px;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;
border-right:1px solid rgba(255,255,255,.1);background:#1b1c1f;}
.o1key-reference-editor-tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-bottom:16px;}
.o1key-reference-editor-tool,.o1key-reference-editor-ratio{height:34px;padding:0 8px;border:1px solid rgba(255,255,255,.12);
min-width:0;white-space:nowrap;border-radius:7px;background:#292b30;color:#c6c8cd;cursor:pointer;font:inherit;}.o1key-reference-editor-tool:hover,.o1key-reference-editor-ratio:hover{
border-color:rgba(255,255,255,.28);color:#fff;}.o1key-reference-editor-tool.active,.o1key-reference-editor-ratio.active{
border-color:#94b78a;background:#344332;color:#f4fff0;box-shadow:inset 0 0 0 1px rgba(180,220,166,.15);}
.o1key-reference-editor-section{margin:0 0 16px;}.o1key-reference-editor-section-title{margin:0 0 7px;color:#9da0a6;font-size:11px;font-weight:700;}
.o1key-reference-editor-ratios{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}.o1key-reference-editor-ratio{height:30px;padding:0 4px;}
.o1key-reference-editor-control{min-width:0;display:grid;gap:7px;margin-top:10px;color:#a8abb0;}.o1key-reference-editor-control-row{min-width:0;display:flex;align-items:center;gap:8px;}
.o1key-reference-editor-control input[type=range]{min-width:0;flex:1;accent-color:#8daa83;}.o1key-reference-editor-control input[type=color]{
width:34px;height:28px;padding:2px;border:1px solid rgba(255,255,255,.14);border-radius:6px;background:#292b30;}
.o1key-reference-editor-width-value{width:44px;text-align:right;color:#d6d8dc;font-variant-numeric:tabular-nums;}
.o1key-reference-editor-sticker-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8f9298;font-size:10px;}
.o1key-reference-editor-sticker-controls>.o1key-reference-editor-action{width:100%;min-width:0;white-space:nowrap;}
.o1key-reference-editor-help{min-height:50px;margin-top:12px;padding:9px;overflow-wrap:anywhere;border-radius:7px;background:rgba(255,255,255,.045);color:#898c92;font-size:10px;}
.o1key-reference-editor-stage{position:relative;min-width:0;min-height:0;display:grid;place-items:center;padding:18px;overflow:hidden;
background-color:#111214;background-image:linear-gradient(45deg,#18191c 25%,transparent 25%),linear-gradient(-45deg,#18191c 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#18191c 75%),linear-gradient(-45deg,transparent 75%,#18191c 75%);background-size:20px 20px;
background-position:0 0,0 10px,10px -10px,-10px 0;}
.o1key-reference-editor-canvas{display:block;max-width:100%;max-height:100%;background:#0a0a0a;box-shadow:0 8px 30px rgba(0,0,0,.5);touch-action:none;outline:none;}
.o1key-reference-editor-footer{min-width:0;display:flex;align-items:center;gap:8px;padding:0 16px;overflow:hidden;border-top:1px solid rgba(255,255,255,.1);}
.o1key-reference-editor-size{flex:0 0 auto;color:#8f9298;font-variant-numeric:tabular-nums;}.o1key-reference-editor-message{
min-width:0;flex:1;margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5a4a4;}
.o1key-reference-editor-action{height:34px;min-width:74px;padding:0 14px;border:0;border-radius:7px;background:#34363b;color:#dedfe2;cursor:pointer;font:inherit;}
.o1key-reference-editor-action:hover{background:#414349;color:#fff;}.o1key-reference-editor-action:disabled{
cursor:not-allowed;opacity:.55;}.o1key-reference-editor-action:disabled:hover{background:#34363b;color:#dedfe2;}
.o1key-reference-editor-action.primary{background:#82a777;color:#10200d;font-weight:750;}.o1key-reference-editor-action.primary:hover{background:#91b685;}
.o1key-reference-editor-action.ghost{min-width:34px;padding:0 9px;}.o1key-reference-editor-history{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;}
.o1key-reference-editor-sidebar-actions>.o1key-reference-editor-action,.o1key-reference-editor-history>.o1key-reference-editor-action{
width:100%;min-width:0;white-space:nowrap;}
@media (max-width:760px){#${EDITOR_ID}{padding:0}.o1key-reference-editor-dialog{width:100vw;height:100vh;min-width:0;min-height:0;border:0;border-radius:0;}
.o1key-reference-editor-body{grid-template-columns:150px minmax(0,1fr)}.o1key-reference-editor-sidebar{padding:10px 8px}.o1key-reference-editor-stage{padding:8px}}
`;
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
export function constrainCropRect(rect, imageWidth, imageHeight, minimumSize = MIN_CROP_SIZE) {
const widthLimit = Math.max(1, Number(imageWidth) || 1);
const heightLimit = Math.max(1, Number(imageHeight) || 1);
const minimum = Math.min(Math.max(1, Number(minimumSize) || 1), widthLimit, heightLimit);
const width = clamp(Number(rect?.width) || minimum, minimum, widthLimit);
const height = clamp(Number(rect?.height) || minimum, minimum, heightLimit);
return {
x: clamp(Number(rect?.x) || 0, 0, widthLimit - width),
y: clamp(Number(rect?.y) || 0, 0, heightLimit - height),
width,
height,
};
}
export function fitCropToRatio(imageWidth, imageHeight, ratio) {
const width = Math.max(1, Number(imageWidth) || 1);
const height = Math.max(1, Number(imageHeight) || 1);
const numericRatio = ratio === "original" ? width / height : Number(ratio);
if (!(numericRatio > 0)) return { x: 0, y: 0, width, height };
let cropWidth = width;
let cropHeight = cropWidth / numericRatio;
if (cropHeight > height) {
cropHeight = height;
cropWidth = cropHeight * numericRatio;
}
return {
x: (width - cropWidth) / 2,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
export function editedReferenceFilename(filename) {
const basename = String(filename || "reference.png").split(/[\\/]/).pop() || "reference.png";
const stem = basename.replace(/\.[^.]+$/, "") || "reference";
const safeStem = stem.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").slice(0, 180) || "reference";
return `${safeStem}_edited.png`;
}
export function fitStickerTransform(imageWidth, imageHeight, canvasWidth, canvasHeight) {
const width = Math.max(1, Number(imageWidth) || 1);
const height = Math.max(1, Number(imageHeight) || 1);
const targetWidth = Math.max(1, Number(canvasWidth) || 1);
const targetHeight = Math.max(1, Number(canvasHeight) || 1);
const scale = Math.min(targetWidth * .5 / width, targetHeight * .5 / height, 1);
return {
x: targetWidth / 2,
y: targetHeight / 2,
width: width * scale,
height: height * scale,
rotation: 0,
opacity: 1,
};
}
function stickerPoint(sticker, localX, localY) {
const cosine = Math.cos(sticker.rotation || 0);
const sine = Math.sin(sticker.rotation || 0);
return {
x: sticker.x + localX * cosine - localY * sine,
y: sticker.y + localX * sine + localY * cosine,
};
}
export function stickerControlGeometry(sticker, rotationHandleDistance = 0) {
const halfWidth = Math.max(1, Number(sticker?.width) || 1) / 2;
const halfHeight = Math.max(1, Number(sticker?.height) || 1) / 2;
return {
corners: [
stickerPoint(sticker, -halfWidth, -halfHeight),
stickerPoint(sticker, halfWidth, -halfHeight),
stickerPoint(sticker, halfWidth, halfHeight),
stickerPoint(sticker, -halfWidth, halfHeight),
],
top: stickerPoint(sticker, 0, -halfHeight),
rotate: stickerPoint(sticker, 0, -halfHeight - rotationHandleDistance),
};
}
export function hitTestSticker(sticker, point, tolerance = 10, rotationHandleDistance = 28) {
if (!sticker || !point) return null;
const geometry = stickerControlGeometry(sticker, rotationHandleDistance);
if (Math.hypot(point.x - geometry.rotate.x, point.y - geometry.rotate.y) <= tolerance) return "rotate";
if (geometry.corners.some((corner) => Math.hypot(point.x - corner.x, point.y - corner.y) <= tolerance)) return "scale";
const cosine = Math.cos(-(sticker.rotation || 0));
const sine = Math.sin(-(sticker.rotation || 0));
const dx = point.x - sticker.x;
const dy = point.y - sticker.y;
const localX = dx * cosine - dy * sine;
const localY = dx * sine + dy * cosine;
return Math.abs(localX) <= sticker.width / 2 && Math.abs(localY) <= sticker.height / 2 ? "move" : null;
}
export function arrowHeadGeometry(start, end, lineWidth = 4) {
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const length = Math.max(12, Math.max(1, Number(lineWidth) || 1) * 4.5);
const spread = Math.PI / 7;
return {
tip: { x: end.x, y: end.y },
left: {
x: end.x - length * Math.cos(angle - spread),
y: end.y - length * Math.sin(angle - spread),
},
right: {
x: end.x - length * Math.cos(angle + spread),
y: end.y - length * Math.sin(angle + spread),
},
};
}
function injectEditorStyles() {
if (document.getElementById(EDITOR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = EDITOR_STYLE_ID;
style.textContent = EDITOR_CSS;
document.head.append(style);
}
function loadEditorImage(sourceUrl) {
return new Promise((resolve, reject) => {
const image = new Image();
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片加载失败,请重新打开编辑器"));
image.src = sourceUrl;
});
}
function canvasBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("无法导出编辑后的图片"));
}, "image/png");
});
}
function cloneSnapshot(state) {
return {
crop: { ...state.crop },
ratioKey: state.ratioKey,
strokes: state.strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => ({ ...point })),
})),
sticker: state.sticker ? { ...state.sticker } : null,
};
}
function drawStroke(context, stroke, scaleX = 1, scaleY = scaleX, offsetX = 0, offsetY = 0) {
if (!stroke?.points?.length) return;
const points = stroke.points;
if (stroke.tool === "arrow") {
if (points.length < 2) return;
const start = {
x: (points[0].x - offsetX) * scaleX,
y: (points[0].y - offsetY) * scaleY,
};
const last = points[points.length - 1];
const end = {
x: (last.x - offsetX) * scaleX,
y: (last.y - offsetY) * scaleY,
};
if (Math.hypot(end.x - start.x, end.y - start.y) < 1) return;
const lineWidth = Math.max(1, stroke.width * ((scaleX + scaleY) / 2));
const head = arrowHeadGeometry(start, end, lineWidth);
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = stroke.color;
context.fillStyle = stroke.color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(start.x, start.y);
context.lineTo(end.x, end.y);
context.stroke();
context.beginPath();
context.moveTo(head.tip.x, head.tip.y);
context.lineTo(head.left.x, head.left.y);
context.lineTo(head.right.x, head.right.y);
context.closePath();
context.fill();
context.restore();
return;
}
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = stroke.tool === "mask" ? "rgba(255,55,79,.52)" : stroke.color;
context.fillStyle = context.strokeStyle;
context.lineWidth = Math.max(1, stroke.width * ((scaleX + scaleY) / 2));
context.beginPath();
if (points.length === 1) {
const point = points[0];
context.arc((point.x - offsetX) * scaleX, (point.y - offsetY) * scaleY, context.lineWidth / 2, 0, Math.PI * 2);
context.fill();
} else {
context.moveTo((points[0].x - offsetX) * scaleX, (points[0].y - offsetY) * scaleY);
for (const point of points.slice(1)) {
context.lineTo((point.x - offsetX) * scaleX, (point.y - offsetY) * scaleY);
}
context.stroke();
}
context.restore();
}
function drawSticker(context, sticker, scaleX = 1, scaleY = scaleX, offsetX = 0, offsetY = 0) {
if (!sticker?.image) return;
context.save();
context.scale(scaleX, scaleY);
context.translate(sticker.x - offsetX, sticker.y - offsetY);
context.rotate(sticker.rotation || 0);
context.globalAlpha = clamp(Number(sticker.opacity ?? 1), 0, 1);
context.drawImage(sticker.image, -sticker.width / 2, -sticker.height / 2, sticker.width, sticker.height);
context.restore();
}
function button(label, className = "", title = "") {
const element = document.createElement("button");
element.type = "button";
element.className = className;
element.textContent = label;
if (title) element.title = title;
return element;
}
export async function openReferenceImageEditor({ sourceUrl, filename = "参考图", onConfirm } = {}) {
if (!sourceUrl) throw new Error("缺少可编辑的参考图地址");
if (typeof onConfirm !== "function") throw new Error("缺少图片编辑保存回调");
document.getElementById(EDITOR_ID)?._o1keyClose?.();
injectEditorStyles();
const sourceImage = await loadEditorImage(sourceUrl);
const sourceWidth = sourceImage.naturalWidth || sourceImage.width;
const sourceHeight = sourceImage.naturalHeight || sourceImage.height;
if (!sourceWidth || !sourceHeight) throw new Error("无法读取图片尺寸");
const previousFocus = document.activeElement;
const overlay = document.createElement("div");
overlay.id = EDITOR_ID;
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "编辑参考图");
const dialog = document.createElement("div");
dialog.className = "o1key-reference-editor-dialog";
const header = document.createElement("div");
header.className = "o1key-reference-editor-header";
const title = document.createElement("strong");
title.textContent = "编辑参考图";
const filenameLabel = document.createElement("span");
filenameLabel.className = "o1key-reference-editor-filename";
filenameLabel.textContent = filename;
const closeButton = button("×", "o1key-reference-editor-close", "关闭");
closeButton.setAttribute("aria-label", "关闭图片编辑器");
header.append(title, filenameLabel, closeButton);
const body = document.createElement("div");
body.className = "o1key-reference-editor-body";
const sidebar = document.createElement("aside");
sidebar.className = "o1key-reference-editor-sidebar";
const tools = document.createElement("div");
tools.className = "o1key-reference-editor-tools";
const toolButtons = new Map();
for (const [tool, label, tooltip] of [
["crop", "裁剪", "拖动裁剪框定位;自由比例下可拖动边角调整"],
["mask", "遮罩", "涂画半透明区域标记,不输出 MASK 数据"],
["brush", "画笔", "自由圈选或批注图片中的物品"],
["sticker", "贴图", "上传上层图片,并移动、旋转或等比缩放"],
["arrow", "箭头", "从箭尾拖向目标位置,箭头尖端指向松开处"],
]) {
const toolButton = button(label, "o1key-reference-editor-tool", tooltip);
toolButton.dataset.tool = tool;
toolButtons.set(tool, toolButton);
tools.append(toolButton);
}
const ratioSection = document.createElement("section");
ratioSection.className = "o1key-reference-editor-section";
const ratioTitle = document.createElement("div");
ratioTitle.className = "o1key-reference-editor-section-title";
ratioTitle.textContent = "快速裁剪";
const ratios = document.createElement("div");
ratios.className = "o1key-reference-editor-ratios";
const ratioButtons = new Map();
for (const preset of REFERENCE_EDITOR_RATIOS) {
const ratioButton = button(preset.label, "o1key-reference-editor-ratio");
ratioButton.dataset.ratio = preset.key;
ratioButtons.set(preset.key, ratioButton);
ratios.append(ratioButton);
}
ratioSection.append(ratioTitle, ratios);
const controlSection = document.createElement("section");
controlSection.className = "o1key-reference-editor-section o1key-reference-editor-control";
const widthRow = document.createElement("label");
widthRow.className = "o1key-reference-editor-control-row";
const widthText = document.createElement("span");
widthText.textContent = "粗细";
const widthInput = document.createElement("input");
widthInput.type = "range";
widthInput.min = "2";
widthInput.max = String(Math.max(24, Math.round(Math.min(sourceWidth, sourceHeight) * 0.12)));
widthInput.value = String(Math.max(6, Math.round(Math.min(sourceWidth, sourceHeight) * 0.012)));
const widthValue = document.createElement("span");
widthValue.className = "o1key-reference-editor-width-value";
widthRow.append(widthText, widthInput, widthValue);
const colorRow = document.createElement("label");
colorRow.className = "o1key-reference-editor-control-row";
const colorText = document.createElement("span");
colorText.textContent = "颜色";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = "#ff334f";
colorRow.append(colorText, colorInput);
controlSection.append(widthRow, colorRow);
const stickerSection = document.createElement("section");
stickerSection.className = "o1key-reference-editor-section o1key-reference-editor-control o1key-reference-editor-sticker-controls";
const stickerInput = document.createElement("input");
stickerInput.type = "file";
stickerInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
stickerInput.hidden = true;
const stickerUpload = button("上传贴图", "o1key-reference-editor-action", "上传一张上层贴图");
const stickerName = document.createElement("div");
stickerName.className = "o1key-reference-editor-sticker-name";
const opacityRow = document.createElement("label");
opacityRow.className = "o1key-reference-editor-control-row";
const opacityText = document.createElement("span");
opacityText.textContent = "透明度";
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.value = "100";
const opacityValue = document.createElement("span");
opacityValue.className = "o1key-reference-editor-width-value";
opacityRow.append(opacityText, opacityInput, opacityValue);
const removeSticker = button("移除贴图", "o1key-reference-editor-action", "移除当前上层贴图");
stickerSection.append(stickerUpload, stickerInput, stickerName, opacityRow, removeSticker);
const help = document.createElement("div");
help.className = "o1key-reference-editor-help";
const sidebarActions = document.createElement("div");
sidebarActions.className = "o1key-reference-editor-control o1key-reference-editor-sidebar-actions";
const historyRow = document.createElement("div");
historyRow.className = "o1key-reference-editor-history";
const undo = button("↶", "o1key-reference-editor-action ghost", "撤销(Ctrl+Z");
const redo = button("↷", "o1key-reference-editor-action ghost", "重做(Ctrl+Y");
const clearMarks = button("清除标记", "o1key-reference-editor-action", "清除遮罩和画笔内容");
historyRow.append(undo, redo);
const reset = button("全部重置", "o1key-reference-editor-action");
sidebarActions.append(historyRow, clearMarks, reset);
sidebar.append(tools, ratioSection, controlSection, stickerSection, help, sidebarActions);
const stage = document.createElement("div");
stage.className = "o1key-reference-editor-stage";
const canvas = document.createElement("canvas");
canvas.className = "o1key-reference-editor-canvas";
canvas.tabIndex = 0;
stage.append(canvas);
body.append(sidebar, stage);
const footer = document.createElement("footer");
footer.className = "o1key-reference-editor-footer";
const sizeLabel = document.createElement("span");
sizeLabel.className = "o1key-reference-editor-size";
const message = document.createElement("span");
message.className = "o1key-reference-editor-message";
const cancel = button("取消", "o1key-reference-editor-action");
const confirm = button("应用编辑", "o1key-reference-editor-action primary");
footer.append(sizeLabel, message, cancel, confirm);
dialog.append(header, body, footer);
overlay.append(dialog);
document.body.append(overlay);
const state = {
mode: "crop",
ratioKey: "original",
crop: fitCropToRatio(sourceWidth, sourceHeight, "original"),
strokes: [],
activeStroke: null,
sticker: null,
pointerAction: null,
canvasWidth: 1,
canvasHeight: 1,
history: [],
historyIndex: -1,
saving: false,
};
const stickerObjectUrls = new Set();
const context = canvas.getContext("2d");
const sourcePoint = (event) => {
const bounds = canvas.getBoundingClientRect();
return {
x: clamp((event.clientX - bounds.left) / Math.max(1, bounds.width) * sourceWidth, 0, sourceWidth),
y: clamp((event.clientY - bounds.top) / Math.max(1, bounds.height) * sourceHeight, 0, sourceHeight),
};
};
const cropDisplay = () => ({
x: state.crop.x / sourceWidth * state.canvasWidth,
y: state.crop.y / sourceHeight * state.canvasHeight,
width: state.crop.width / sourceWidth * state.canvasWidth,
height: state.crop.height / sourceHeight * state.canvasHeight,
});
const updateControls = () => {
for (const [tool, toolButton] of toolButtons) toolButton.classList.toggle("active", tool === state.mode);
for (const [key, ratioButton] of ratioButtons) ratioButton.classList.toggle("active", key === state.ratioKey);
ratioSection.hidden = state.mode !== "crop";
controlSection.hidden = state.mode === "crop" || state.mode === "sticker";
stickerSection.hidden = state.mode !== "sticker";
colorRow.hidden = state.mode !== "brush" && state.mode !== "arrow";
clearMarks.hidden = state.mode !== "mask" && state.mode !== "brush" && state.mode !== "arrow";
reset.textContent = state.mode === "crop" ? "重置裁剪" : state.mode === "sticker" ? "重置贴图" : "全部重置";
widthValue.textContent = `${widthInput.value}px`;
stickerUpload.textContent = state.sticker ? "更换贴图" : "上传贴图";
stickerName.textContent = state.sticker?.name || "尚未上传贴图";
opacityInput.value = String(Math.round((state.sticker?.opacity ?? 1) * 100));
opacityValue.textContent = `${opacityInput.value}%`;
opacityInput.disabled = !state.sticker;
removeSticker.disabled = !state.sticker;
reset.disabled = state.mode === "sticker" && !state.sticker;
help.textContent = state.mode === "crop"
? "选择比例即可得到居中最大裁剪框;拖动框可调整取景。自由比例支持拖动边角。"
: state.mode === "mask"
? "半透明红色只作为图片上的视觉标记,会合成进参考图,不会创建 MASK 数据。"
: state.mode === "brush"
? "用画笔自由圈选或标注物品。可调整颜色和线条粗细。"
: state.mode === "arrow"
? "从箭尾按下并拖向目标,松开位置就是箭头尖端。可调整颜色和粗细。"
: state.sticker
? "拖动贴图移动;拖动四角等比缩放;拖动顶部圆点旋转。按住 Shift 可按 15° 吸附。"
: "上传图片后,它会作为上层贴图置于画面中央。";
undo.disabled = state.historyIndex <= 0;
redo.disabled = state.historyIndex >= state.history.length - 1;
clearMarks.disabled = state.strokes.length === 0;
sizeLabel.textContent = `${Math.max(1, Math.round(state.crop.width))} × ${Math.max(1, Math.round(state.crop.height))} px`;
};
const drawCropOverlay = () => {
const crop = cropDisplay();
context.save();
context.fillStyle = "rgba(0,0,0,.56)";
context.beginPath();
context.rect(0, 0, state.canvasWidth, state.canvasHeight);
context.rect(crop.x, crop.y, crop.width, crop.height);
context.fill("evenodd");
context.strokeStyle = "rgba(255,255,255,.96)";
context.lineWidth = 1.5;
context.strokeRect(crop.x + .75, crop.y + .75, Math.max(0, crop.width - 1.5), Math.max(0, crop.height - 1.5));
context.strokeStyle = "rgba(255,255,255,.4)";
context.lineWidth = 1;
for (const part of [1 / 3, 2 / 3]) {
context.beginPath();
context.moveTo(crop.x + crop.width * part, crop.y);
context.lineTo(crop.x + crop.width * part, crop.y + crop.height);
context.moveTo(crop.x, crop.y + crop.height * part);
context.lineTo(crop.x + crop.width, crop.y + crop.height * part);
context.stroke();
}
if (state.ratioKey === "free") {
context.fillStyle = "#fff";
for (const [x, y] of [
[crop.x, crop.y], [crop.x + crop.width, crop.y],
[crop.x, crop.y + crop.height], [crop.x + crop.width, crop.y + crop.height],
]) context.fillRect(x - 5, y - 5, 10, 10);
}
context.restore();
};
const drawStickerControls = () => {
if (!state.sticker) return;
const scaleX = state.canvasWidth / sourceWidth;
const scaleY = state.canvasHeight / sourceHeight;
const sourceScale = Math.max(scaleX, scaleY);
const geometry = stickerControlGeometry(state.sticker, 28 / sourceScale);
const displayPoint = (point) => ({ x: point.x * scaleX, y: point.y * scaleY });
const corners = geometry.corners.map(displayPoint);
const top = displayPoint(geometry.top);
const rotateHandle = displayPoint(geometry.rotate);
context.save();
context.strokeStyle = "rgba(255,255,255,.96)";
context.fillStyle = "#8daa83";
context.lineWidth = 1.5;
context.beginPath();
context.moveTo(corners[0].x, corners[0].y);
for (const corner of corners.slice(1)) context.lineTo(corner.x, corner.y);
context.closePath();
context.stroke();
context.beginPath();
context.moveTo(top.x, top.y);
context.lineTo(rotateHandle.x, rotateHandle.y);
context.stroke();
for (const corner of corners) {
context.fillRect(corner.x - 5, corner.y - 5, 10, 10);
context.strokeRect(corner.x - 5, corner.y - 5, 10, 10);
}
context.beginPath();
context.arc(rotateHandle.x, rotateHandle.y, 6, 0, Math.PI * 2);
context.fill();
context.stroke();
context.restore();
};
const render = () => {
const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, state.canvasWidth, state.canvasHeight);
context.drawImage(sourceImage, 0, 0, state.canvasWidth, state.canvasHeight);
drawSticker(context, state.sticker, state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
const allStrokes = state.activeStroke ? [...state.strokes, state.activeStroke] : state.strokes;
for (const stroke of allStrokes) {
drawStroke(context, stroke, state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
}
if (state.mode === "crop") drawCropOverlay();
if (state.mode === "sticker") drawStickerControls();
updateControls();
};
const resizeCanvas = () => {
const availableWidth = Math.max(240, (stage.clientWidth || window.innerWidth * .7) - 36);
const availableHeight = Math.max(220, (stage.clientHeight || window.innerHeight * .72) - 36);
const scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, 1);
state.canvasWidth = Math.max(1, Math.round(sourceWidth * scale));
state.canvasHeight = Math.max(1, Math.round(sourceHeight * scale));
const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(state.canvasWidth * pixelRatio));
canvas.height = Math.max(1, Math.round(state.canvasHeight * pixelRatio));
canvas.style.width = `${state.canvasWidth}px`;
canvas.style.height = `${state.canvasHeight}px`;
render();
};
const pushHistory = () => {
state.history.splice(state.historyIndex + 1);
state.history.push(cloneSnapshot(state));
if (state.history.length > 40) state.history.shift();
state.historyIndex = state.history.length - 1;
updateControls();
};
const restoreHistory = (index) => {
const snapshot = state.history[index];
if (!snapshot) return;
state.historyIndex = index;
state.crop = { ...snapshot.crop };
state.ratioKey = snapshot.ratioKey;
state.strokes = snapshot.strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => ({ ...point })),
}));
state.sticker = snapshot.sticker ? { ...snapshot.sticker } : null;
state.activeStroke = null;
render();
};
const hitCrop = (point) => {
const tolerance = 12 / Math.max(state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
const crop = state.crop;
if (state.ratioKey === "free") {
const corners = {
nw: [crop.x, crop.y], ne: [crop.x + crop.width, crop.y],
sw: [crop.x, crop.y + crop.height], se: [crop.x + crop.width, crop.y + crop.height],
};
for (const [handle, [x, y]] of Object.entries(corners)) {
if (Math.hypot(point.x - x, point.y - y) <= tolerance) return handle;
}
}
if (
point.x >= crop.x && point.x <= crop.x + crop.width
&& point.y >= crop.y && point.y <= crop.y + crop.height
) return "move";
return "create";
};
const updateFreeCrop = (action, point) => {
const origin = action.origin;
if (action.kind === "move") {
state.crop = constrainCropRect({
...action.crop,
x: action.crop.x + point.x - origin.x,
y: action.crop.y + point.y - origin.y,
}, sourceWidth, sourceHeight);
return;
}
const anchor = action.kind === "create" ? origin : {
x: action.kind.includes("w") ? action.crop.x + action.crop.width : action.crop.x,
y: action.kind.includes("n") ? action.crop.y + action.crop.height : action.crop.y,
};
state.crop = constrainCropRect({
x: Math.min(anchor.x, point.x),
y: Math.min(anchor.y, point.y),
width: Math.max(MIN_CROP_SIZE, Math.abs(point.x - anchor.x)),
height: Math.max(MIN_CROP_SIZE, Math.abs(point.y - anchor.y)),
}, sourceWidth, sourceHeight);
};
const moveFixedCrop = (action, point) => {
state.crop = constrainCropRect({
...action.crop,
x: action.crop.x + point.x - action.origin.x,
y: action.crop.y + point.y - action.origin.y,
}, sourceWidth, sourceHeight);
};
const resetStickerTransform = () => {
if (!state.sticker?.image) return;
const fitted = fitStickerTransform(
state.sticker.image.naturalWidth || state.sticker.image.width,
state.sticker.image.naturalHeight || state.sticker.image.height,
sourceWidth,
sourceHeight,
);
state.sticker = {
...state.sticker,
...fitted,
};
};
const stickerHit = (point) => {
const sourceScale = Math.max(state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
return hitTestSticker(state.sticker, point, 11 / sourceScale, 28 / sourceScale);
};
const updateStickerTransform = (action, point, event) => {
if (!state.sticker || !action?.sticker) return;
if (action.kind === "move") {
state.sticker.x = clamp(action.sticker.x + point.x - action.origin.x, 0, sourceWidth);
state.sticker.y = clamp(action.sticker.y + point.y - action.origin.y, 0, sourceHeight);
} else if (action.kind === "scale") {
const initialDistance = Math.max(1, Math.hypot(
action.origin.x - action.sticker.x,
action.origin.y - action.sticker.y,
));
const currentDistance = Math.max(1, Math.hypot(
point.x - action.sticker.x,
point.y - action.sticker.y,
));
const minimumScale = 16 / Math.max(1, Math.min(action.sticker.width, action.sticker.height));
const maximumScale = Math.max(sourceWidth, sourceHeight) * 4
/ Math.max(1, Math.max(action.sticker.width, action.sticker.height));
const scale = clamp(currentDistance / initialDistance, minimumScale, maximumScale);
state.sticker.width = action.sticker.width * scale;
state.sticker.height = action.sticker.height * scale;
} else if (action.kind === "rotate") {
let rotation = Math.atan2(point.y - action.sticker.y, point.x - action.sticker.x) - action.angleOffset;
if (event?.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12);
state.sticker.rotation = rotation;
}
};
stickerUpload.addEventListener("click", () => stickerInput.click());
stickerInput.addEventListener("change", async () => {
const file = stickerInput.files?.[0];
stickerInput.value = "";
if (!file) return;
if (!file.type?.startsWith("image/")) {
message.textContent = "请选择图片文件";
return;
}
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadEditorImage(objectUrl);
stickerObjectUrls.add(objectUrl);
state.sticker = {
image,
name: file.name,
objectUrl,
...fitStickerTransform(
image.naturalWidth || image.width,
image.naturalHeight || image.height,
sourceWidth,
sourceHeight,
),
};
state.mode = "sticker";
message.textContent = "";
pushHistory();
render();
} catch (error) {
URL.revokeObjectURL(objectUrl);
message.textContent = error?.message || String(error);
}
});
opacityInput.addEventListener("input", () => {
if (!state.sticker) return;
state.sticker.opacity = Number(opacityInput.value) / 100;
render();
});
opacityInput.addEventListener("change", () => {
if (state.sticker) pushHistory();
});
removeSticker.addEventListener("click", () => {
if (!state.sticker) return;
state.sticker = null;
pushHistory();
render();
});
for (const [tool, toolButton] of toolButtons) {
toolButton.addEventListener("click", () => {
state.mode = tool;
canvas.style.cursor = tool === "crop" ? "move" : tool === "sticker" ? (state.sticker ? "grab" : "default") : "crosshair";
render();
});
}
for (const preset of REFERENCE_EDITOR_RATIOS) {
ratioButtons.get(preset.key).addEventListener("click", () => {
state.mode = "crop";
state.ratioKey = preset.key;
if (preset.key !== "free") state.crop = fitCropToRatio(sourceWidth, sourceHeight, preset.ratio);
pushHistory();
render();
});
}
widthInput.addEventListener("input", render);
colorInput.addEventListener("input", render);
undo.addEventListener("click", () => restoreHistory(state.historyIndex - 1));
redo.addEventListener("click", () => restoreHistory(state.historyIndex + 1));
clearMarks.addEventListener("click", () => {
if (!state.strokes.length) return;
state.strokes = [];
pushHistory();
render();
});
reset.addEventListener("click", () => {
if (state.mode === "crop") {
state.ratioKey = "original";
state.crop = fitCropToRatio(sourceWidth, sourceHeight, "original");
} else if (state.mode === "sticker") {
resetStickerTransform();
} else {
state.strokes = [];
state.activeStroke = null;
}
pushHistory();
render();
});
canvas.addEventListener("pointerdown", (event) => {
if (state.saving) return;
const point = sourcePoint(event);
canvas.setPointerCapture?.(event.pointerId);
if (state.mode === "crop") {
const kind = hitCrop(point);
state.pointerAction = { target: "crop", kind, origin: point, crop: { ...state.crop } };
if (kind === "create") {
state.ratioKey = "free";
updateFreeCrop(state.pointerAction, point);
}
} else if (state.mode === "sticker") {
const kind = stickerHit(point);
if (!kind) return;
state.pointerAction = {
target: "sticker",
kind,
origin: point,
sticker: { ...state.sticker },
angleOffset: Math.atan2(point.y - state.sticker.y, point.x - state.sticker.x)
- state.sticker.rotation,
};
canvas.style.cursor = kind === "move" ? "grabbing" : kind === "scale" ? "nwse-resize" : "crosshair";
} else {
state.activeStroke = {
tool: state.mode,
color: colorInput.value,
width: Number(widthInput.value),
points: [point],
};
}
event.preventDefault();
render();
});
canvas.addEventListener("pointermove", (event) => {
const point = sourcePoint(event);
if (state.activeStroke) {
const previous = state.activeStroke.points[state.activeStroke.points.length - 1];
if (state.activeStroke.tool === "arrow") {
if (state.activeStroke.points.length === 1) state.activeStroke.points.push(point);
else state.activeStroke.points[1] = point;
} else if (Math.hypot(point.x - previous.x, point.y - previous.y) >= 1) {
state.activeStroke.points.push(point);
}
render();
return;
}
if (!state.pointerAction) return;
if (state.pointerAction.target === "sticker") {
updateStickerTransform(state.pointerAction, point, event);
} else if (state.ratioKey === "free") updateFreeCrop(state.pointerAction, point);
else moveFixedCrop(state.pointerAction, point);
render();
});
canvas.addEventListener("pointermove", (event) => {
if (state.mode !== "sticker" || state.pointerAction || state.activeStroke) return;
const kind = stickerHit(sourcePoint(event));
canvas.style.cursor = kind === "move" ? "grab" : kind === "scale" ? "nwse-resize" : kind === "rotate" ? "crosshair" : "default";
});
const finishPointer = () => {
if (state.activeStroke) {
const points = state.activeStroke.points;
const validArrow = state.activeStroke.tool !== "arrow" || (
points.length > 1
&& Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) >= 1
);
if (validArrow) state.strokes.push(state.activeStroke);
state.activeStroke = null;
if (validArrow) pushHistory();
} else if (state.pointerAction) pushHistory();
state.pointerAction = null;
render();
};
canvas.addEventListener("pointerup", finishPointer);
canvas.addEventListener("pointercancel", finishPointer);
const close = () => {
if (state.saving) return;
window.removeEventListener("resize", resizeCanvas);
for (const objectUrl of stickerObjectUrls) URL.revokeObjectURL(objectUrl);
stickerObjectUrls.clear();
overlay.remove();
previousFocus?.focus?.();
};
overlay._o1keyClose = close;
closeButton.addEventListener("click", close);
cancel.addEventListener("click", close);
overlay.addEventListener("mousedown", (event) => {
if (event.target === overlay) close();
});
overlay.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.ctrlKey && event.key.toLowerCase() === "z") {
event.preventDefault();
restoreHistory(state.historyIndex + (event.shiftKey ? 1 : -1));
} else if (event.ctrlKey && event.key.toLowerCase() === "y") {
event.preventDefault();
restoreHistory(state.historyIndex + 1);
}
});
confirm.addEventListener("click", async () => {
if (state.saving) return;
state.saving = true;
confirm.disabled = true;
cancel.disabled = true;
confirm.textContent = "保存中…";
message.textContent = "";
try {
const crop = constrainCropRect(state.crop, sourceWidth, sourceHeight, 1);
const output = document.createElement("canvas");
output.width = Math.max(1, Math.round(crop.width));
output.height = Math.max(1, Math.round(crop.height));
const outputContext = output.getContext("2d");
outputContext.drawImage(
sourceImage,
crop.x, crop.y, crop.width, crop.height,
0, 0, output.width, output.height,
);
const scaleX = output.width / crop.width;
const scaleY = output.height / crop.height;
drawSticker(outputContext, state.sticker, scaleX, scaleY, crop.x, crop.y);
for (const stroke of state.strokes) drawStroke(outputContext, stroke, scaleX, scaleY, crop.x, crop.y);
const blob = await canvasBlob(output);
await onConfirm({
blob,
filename: editedReferenceFilename(filename),
width: output.width,
height: output.height,
});
state.saving = false;
close();
} catch (error) {
state.saving = false;
confirm.disabled = false;
cancel.disabled = false;
confirm.textContent = "应用编辑";
message.textContent = error?.message || String(error);
}
});
pushHistory();
window.addEventListener("resize", resizeCanvas);
requestAnimationFrame(resizeCanvas);
overlay.tabIndex = -1;
overlay.focus();
return overlay;
}
+100
View File
@@ -0,0 +1,100 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
const RELEASE_URL = "https://git.o1key.com/publisher/comfyui_o1key";
let registered = false;
app.registerExtension({
name: "o1key.updateButton",
setup() {
if (registered || app.extensionManager.getSidebarTabs?.().some((tab) => tab.id === "o1key-update")) return;
registered = true;
app.extensionManager.registerSidebarTab({
id: "o1key-update",
title: "更新",
tooltip: "更新 O1Key 节点包",
icon: "pi pi-download",
type: "custom",
render(container) {
const root = document.createElement("div");
root.id = "o1key-update-panel";
root.style.padding = "16px";
const title = document.createElement("h3");
title.textContent = "更新 O1Key 节点包";
const description = document.createElement("p");
description.textContent = "从 O1Key 发布仓库的 main 分支获取更新。只允许安全快进,不会清理或覆盖本地文件。";
const source = document.createElement("p");
source.textContent = `发布地址:${RELEASE_URL}`;
const button = document.createElement("button");
button.type = "button";
button.textContent = "检查并更新";
button.className = "p-button";
const status = document.createElement("p");
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
const suggestion = document.createElement("p");
function show(message, nextStep = "", error = false) {
status.textContent = message;
status.style.color = error ? "var(--error-color, #ef7777)" : "";
suggestion.textContent = nextStep;
}
button.addEventListener("click", async () => {
if (button.disabled || !window.confirm(`${RELEASE_URL} 的 main 分支检查并更新?本地修改不会被覆盖。`)) return;
button.disabled = true;
show("正在检查本地文件并连接发布仓库…", "请保持 ComfyUI 运行,等待结果。");
try {
const response = await api.fetchApi("/o1key/update", {
method: "POST",
headers: { "X-O1Key-Update": "1" },
});
if (response.status === 404) {
show("更新接口尚未加载。", "重启 ComfyUI 并刷新浏览器后再试。", true);
return;
}
let result;
try {
result = await response.json();
} catch {
show("无法读取更新结果。", "确认 ComfyUI 正常运行,刷新页面后重试。", true);
return;
}
if (!response.ok) {
show(result.error || "更新未完成。", result.suggestion || "检查 ComfyUI 日志后重试。", true);
return;
}
if (typeof result.updated !== "boolean" || typeof result.version !== "string") {
show("更新接口返回了无效结果。", "重启 ComfyUI 并刷新浏览器后重试。", true);
return;
}
if (result.updated) {
show(
`已更新到 ${result.version}`,
result.requirements_changed
? "依赖列表已变化:先在 ComfyUI 使用的 Python 环境中安装 requirements.txt,再重启 ComfyUI。"
: "请重启 ComfyUI,并刷新浏览器以加载新版本。",
);
} else {
show(`已是最新版本(${result.version})。`, "无需重启。");
}
} catch {
show("无法连接本地 ComfyUI 更新接口。", "确认 ComfyUI 正在运行,刷新页面后重试。", true);
} finally {
button.disabled = false;
}
});
root.append(title, description, source, button, status, suggestion);
container.replaceChildren(root);
},
});
},
});
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPE = "O1keyOmniFlashVideo";
const REFERENCE_GROUP = "参考图片";
const MEDIA_INPUTS = ["首帧图片", "尾帧图片", "源视频"];
const MODE_INPUTS = {
"文生视频": [],
"参考图视频": [REFERENCE_GROUP],
"首尾帧": ["首帧图片", "尾帧图片"],
"视频编辑": [REFERENCE_GROUP, "源视频"],
};
function isReferenceInput(input) {
return input.name.startsWith(`${REFERENCE_GROUP}.`)
|| /^参考图片\d+$/.test(input.name);
}
function inputOptions(input) {
return { label: input.label ?? input.name.split(".").at(-1), shape: input.shape };
}
function syncModeInputs(node) {
const state = node._o1keyOmniModeInputs;
if (!state || state.syncing) return;
const visible = MODE_INPUTS[state.mode.value] ?? MODE_INPUTS["文生视频"];
state.syncing = true;
try {
// Disable Autogrow before removing reference sockets; disconnects must
// not recreate inputs belonging to the previous mode.
if (visible.includes(REFERENCE_GROUP)) {
node.comfyDynamic.autogrow[REFERENCE_GROUP] = state.autogrow;
} else {
delete node.comfyDynamic.autogrow[REFERENCE_GROUP];
}
for (let index = node.inputs.length - 1; index >= 0; index--) {
const input = node.inputs[index];
const active = isReferenceInput(input)
? visible.includes(REFERENCE_GROUP)
: !MEDIA_INPUTS.includes(input.name) || visible.includes(input.name);
if (!active) node.removeInput(index);
}
for (const name of visible) {
const template = state.templates[name];
const exists = name === REFERENCE_GROUP
? node.inputs.some(isReferenceInput)
: node.inputs.some((input) => input.name === name);
if (!exists) node.addInput(template.name, template.type, inputOptions(template));
}
} finally {
state.syncing = false;
}
if (typeof node.computeSize === "function" && typeof node.setSize === "function") {
const size = node.computeSize([...node.size]);
node.setSize([node.size[0], size[1]]);
}
node.setDirtyCanvas?.(true, true);
}
function installModeInputs(node) {
if ((node?.comfyClass || node?.type) !== NODE_TYPE || node._o1keyOmniModeInputs) return;
const mode = node.widgets?.find((widget) => widget.name === "生成模式");
const autogrow = node.comfyDynamic?.autogrow?.[REFERENCE_GROUP];
if (!mode || !autogrow || !node.inputs || !node.addInput || !node.removeInput) return;
const reference = node.inputs.find(isReferenceInput) ?? {
name: `${REFERENCE_GROUP}.${autogrow.names?.[0] ?? "参考图片1"}`,
type: "IMAGE",
};
const templates = { [REFERENCE_GROUP]: { ...reference } };
for (const name of MEDIA_INPUTS) {
const type = name === "源视频" ? "VIDEO" : "IMAGE";
templates[name] = { ...(node.inputs.find((input) => input.name === name) ?? { name, type }) };
}
node._o1keyOmniModeInputs = { mode, autogrow, templates, syncing: false };
const originalConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function () {
if (node._o1keyOmniModeInputs.syncing) return;
return originalConnectionsChange?.apply(this, arguments);
};
const originalCallback = mode.callback;
mode.callback = function () {
const result = originalCallback?.apply(this, arguments);
syncModeInputs(node);
return result;
};
syncModeInputs(node);
}
function installRunButton(node) {
if ((node?.comfyClass || node?.type) !== NODE_TYPE || node._o1keyOmniRunButton) return;
const button = node.addWidget("button", "开始生成", null, async () => {
if (node._o1keyOmniSubmitting) return;
node._o1keyOmniSubmitting = true;
button.disabled = true;
try {
const queued = await app.queuePrompt(0, 1, [node.id]);
if (queued === false) throw new Error("ComfyUI 未接受本次任务");
} catch (error) {
app.extensionManager?.toast?.add?.({
severity: "error",
summary: "Omni Flash 提交失败",
detail: String(error?.message || error),
});
} finally {
button.disabled = false;
node._o1keyOmniSubmitting = false;
}
}, { serialize: false });
button.serializeValue = () => undefined;
node._o1keyOmniRunButton = button;
}
app.registerExtension({
name: "o1key.omniFlashVideo",
nodeCreated(node) {
installRunButton(node);
installModeInputs(node);
},
loadedGraphNode(node) {
installRunButton(node);
installModeInputs(node);
syncModeInputs(node);
},
});
+80
View File
@@ -0,0 +1,80 @@
// K 视频节点 · 专业提示词词库
// 结构:每个分类 { name, multi(是否多选), tags:[{label, prompt}] }
// label = 词条按钮显示文字;prompt = 实际注入到提示词框的专业短句。
// 单选(multi:false):同一分类内点击新词条会替换旧选择;
// 多选(multi:true):可叠加多个词条。
export const PROMPT_LIBRARY = [
// ── 通用视频专业词 ──────────────────────────────────────────────
{ name: "运镜", multi: false, tags: [
{ label: "推进", prompt: "镜头向前推进,逐渐贴近主体" },
{ label: "拉远", prompt: "镜头缓缓拉远,逐渐展现全貌" },
{ label: "摇镜", prompt: "镜头原地水平摇移,扫过场景" },
{ label: "移镜", prompt: "镜头沿水平方向平稳移动" },
{ label: "跟随", prompt: "镜头跟随主体移动,保持构图" },
{ label: "环绕", prompt: "镜头围绕主体平滑环绕一周" },
{ label: "升降", prompt: "镜头垂直升降,改变观察高度" },
{ label: "手持", prompt: "手持镜头,带轻微晃动的真实感" },
{ label: "固定镜头", prompt: "固定机位,画面稳定不动" },
{ label: "希区柯克变焦", prompt: "推轨变焦,背景透视急剧压缩的眩晕感" },
]},
{ name: "运镜速度", multi: false, tags: [
{ label: "慢速", prompt: "镜头运动缓慢,节奏舒缓" },
{ label: "匀速", prompt: "镜头匀速平稳运动" },
{ label: "快速", prompt: "镜头快速运动,节奏明快" },
{ label: "先快后慢", prompt: "镜头由快渐慢,富有节奏变化" },
]},
{ name: "景别", multi: false, tags: [
{ label: "大远景", prompt: "大远景,主体融入广阔环境" },
{ label: "全景", prompt: "全景构图,完整呈现主体与场景" },
{ label: "中景", prompt: "中景构图,主体占据画面主要位置" },
{ label: "近景", prompt: "近景,突出主体上半部分" },
{ label: "特写", prompt: "特写镜头,主体充满画面" },
{ label: "大特写", prompt: "大特写,极致放大局部细节" },
{ label: "仰拍", prompt: "仰视角度,主体显得高大有气势" },
{ label: "俯拍", prompt: "俯视角度,平铺呈现主体" },
]},
{ name: "光影", multi: true, tags: [
{ label: "自然光", prompt: "柔和真实的自然光线" },
{ label: "柔光", prompt: "柔光打亮,阴影柔和过渡" },
{ label: "硬光", prompt: "硬光塑造,明暗对比强烈,轮廓立体" },
{ label: "逆光", prompt: "逆光勾勒边缘,形成通透光晕" },
{ label: "侧光", prompt: "侧光照射,强化立体感与质感" },
{ label: "伦勃朗光", prompt: "伦勃朗布光,面部三角光,质感高级" },
{ label: "黄金时刻", prompt: "黄金时刻暖阳斜射,氛围温暖" },
{ label: "霓虹光", prompt: "霓虹彩色光影,时尚都市氛围" },
{ label: "丁达尔光", prompt: "丁达尔光束穿透,体积光弥漫" },
]},
{ name: "画面", multi: true, tags: [
{ label: "电影感", prompt: "电影级画面质感,宽幅构图" },
{ label: "浅景深", prompt: "大光圈浅景深,背景柔和虚化" },
{ label: "高对比", prompt: "高对比度,明暗层次分明" },
{ label: "胶片质感", prompt: "胶片颗粒质感,色调复古" },
{ label: "4K超清", prompt: "4K超高清细节,画面锐利通透" },
{ label: "丰富细节", prompt: "画面细节丰富,纹理清晰" },
{ label: "背景简约", prompt: "背景简洁干净,主体突出" },
]},
{ name: "氛围", multi: true, tags: [
{ label: "神秘", prompt: "神秘幽暗的氛围" },
{ label: "宁静", prompt: "宁静平和的氛围" },
{ label: "温馨", prompt: "温馨治愈的暖调氛围" },
{ label: "生动", prompt: "生动鲜活、富有生命力" },
{ label: "高级感", prompt: "高级质感氛围,精致考究" },
{ label: "梦幻", prompt: "梦幻唯美的氛围" },
{ label: "复古", prompt: "复古怀旧的色调氛围" },
]},
// ── 电商专区(淘宝服装 / 珠宝 / 摄影)──────────────────────────────
{ name: "电商专区", multi: true, tags: [
{ label: "模特展示", prompt: "模特自然走动展示服装,呈现真实上身效果" },
{ label: "衣摆飘动", prompt: "衣摆与面料在微风中轻盈飘动,展现垂坠感" },
{ label: "面料特写", prompt: "面料纹理特写,呈现材质与做工细节" },
{ label: "360°试穿", prompt: "模特原地缓慢旋转,360度展示服装版型" },
{ label: "珠宝微距", prompt: "微距镜头极致放大,呈现切割工艺与光泽" },
{ label: "火彩闪耀", prompt: "宝石折射出璀璨火彩,光芒流转" },
{ label: "黑绒背景", prompt: "纯黑丝绒背景衬托,质感奢华聚焦" },
{ label: "影棚布光", prompt: "专业影棚布光,光线均匀干净" },
{ label: "纯色背景", prompt: "纯色无缝背景,简洁聚焦产品" },
{ label: "产品悬浮", prompt: "产品悬浮于空中,干净利落的展示感" },
{ label: "镜面倒影", prompt: "产品置于镜面台面,清晰倒影增强高级感" },
]},
];
+285
View File
@@ -0,0 +1,285 @@
import { app } from "../../../scripts/app.js";
import { PROMPT_LIBRARY } from "./promptLibrary.data.js";
// ── 适用节点 ──────────────────────────────────────────────────────────────
const TARGET_NODES = ["K3Video", "K3MotionControl"];
const STYLE_ID = "o1key-plib-styles";
const DIALOG_ID = "o1key-plib-dialog";
// 记录每个节点最近聚焦的提示词框 widget 名
const lastFocused = new WeakMap();
let dialogRoot = null;
let activeNode = null;
// 单选分类的当前选择:{ [分类名]: 词条label };多选分类:Set
const selected = {}; // 单选
const multiSelected = {}; // 多选 → Set
const CSS = `
#o1key-plib-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-plib-modal{width:min(460px,calc(100vw - 32px));height:min(760px,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-plib-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-plib-title{font-size:15px;font-weight:700;color:#eee}
.o1pl-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}
.o1pl-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
#o1pl-body{flex:1;min-height:0;overflow:auto;padding:14px 16px}
#o1pl-body::-webkit-scrollbar{width:6px}
#o1pl-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
.o1pl-cat{margin-bottom:18px}
.o1pl-cat-title{font-size:13px;font-weight:700;color:#cfcfcf;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.o1pl-cat-multi{font-size:11px;font-weight:400;color:#777}
.o1pl-tags{display:flex;flex-wrap:wrap;gap:8px}
.o1pl-tag{padding:7px 14px;border:1px solid rgba(255,255,255,.16);border-radius:18px;background:rgba(255,255,255,.03);color:#ccc;font-size:13px;cursor:pointer;transition:all .12s;font-family:inherit}
.o1pl-tag:hover{background:rgba(255,255,255,.08);color:#fff}
.o1pl-tag.active{border-color:#4ade80;color:#4ade80;background:rgba(74,222,128,.08)}
#o1pl-footer{flex-shrink:0;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:flex;flex-direction:column;gap:8px}
#o1pl-preview{font-size:12px;color:#9a9a9a;line-height:1.5;max-height:54px;overflow:auto;min-height:18px}
#o1pl-preview::-webkit-scrollbar{width:5px}
#o1pl-preview::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
#o1pl-footer-row{display:flex;gap:8px;align-items:center}
#o1pl-target{flex:1;font-size:11px;color:#777;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.o1pl-btn{padding:8px 18px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;font-family:inherit;font-weight:600}
.o1pl-btn-ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);color:#bbb}
.o1pl-btn-ghost:hover{background:rgba(255,255,255,.1);color:#eee}
.o1pl-btn-primary{background:#fff;color:#111}
.o1pl-btn-primary:hover{background:#e6e6e6}
`;
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>`;
}
// ── 提示词框定位 ──────────────────────────────────────────────────────────
// 匹配任意「…提示词…」框,但排除「负向提示词 / negative」。
// 用包含匹配(而非前缀),以兼容 DynamicCombo 可能给子输入加的前缀名。
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?.o1plFocusBound) continue;
el.dataset.o1plFocusBound = "1";
el.addEventListener("focus", () => lastFocused.set(node, w.name));
}
}
function setWidgetValue(node, widget, value) {
widget.value = value;
const el = widget.element || widget.inputEl;
if (el && "value" in el) el.value = value;
widget.callback?.(value);
node.setDirtyCanvas?.(true, true);
}
// 智能逗号拼接:自动在已有文本后补「,」
function appendToPrompt(node, widget, addition) {
const cur = String(widget.value || "").trim();
let next;
if (!cur) {
next = addition;
} else {
const sep = /[,。.\s]$/.test(cur) ? "" : "";
next = cur + sep + addition;
}
setWidgetValue(node, widget, next);
}
// ── 选择状态 → 组装短句 ───────────────────────────────────────────────────
function buildAssembled() {
const parts = [];
for (const cat of PROMPT_LIBRARY) {
if (cat.multi) {
const set = multiSelected[cat.name];
if (!set || !set.size) continue;
for (const tag of cat.tags) if (set.has(tag.label)) parts.push(tag.prompt);
} else {
const lbl = selected[cat.name];
if (!lbl) continue;
const tag = cat.tags.find(t => t.label === lbl);
if (tag) parts.push(tag.prompt);
}
}
return parts.join("");
}
function refreshPreview() {
const el = dialogRoot?.querySelector("#o1pl-preview");
if (!el) return;
const text = buildAssembled();
el.textContent = text || "点击词条组合提示词,预览将显示在这里…";
el.style.color = text ? "#cfcfcf" : "#666";
const tgt = dialogRoot?.querySelector("#o1pl-target");
if (tgt && activeNode) {
const w = resolveTargetWidget(activeNode);
tgt.textContent = w ? `→ 插入到「${w.name}` : "(未找到提示词框)";
}
}
// ── 渲染:灵感词库 tab ────────────────────────────────────────────────────
function renderLibraryTab() {
const body = dialogRoot.querySelector("#o1pl-body");
body.innerHTML = PROMPT_LIBRARY.map(cat => {
const tags = cat.tags.map(tag => {
let active;
if (cat.multi) active = multiSelected[cat.name]?.has(tag.label);
else active = selected[cat.name] === tag.label;
return `<button class="o1pl-tag${active ? " active" : ""}" data-cat="${escapeHtml(cat.name)}" data-label="${escapeHtml(tag.label)}" title="${escapeHtml(tag.prompt)}">${escapeHtml(tag.label)}</button>`;
}).join("");
const multiHint = cat.multi ? `<span class="o1pl-cat-multi">(多选)</span>` : "";
return `<div class="o1pl-cat"><div class="o1pl-cat-title">${escapeHtml(cat.name)}${multiHint}</div><div class="o1pl-tags">${tags}</div></div>`;
}).join("");
body.querySelectorAll(".o1pl-tag").forEach(btn => {
btn.addEventListener("click", () => toggleTag(btn.dataset.cat, btn.dataset.label));
});
}
function toggleTag(catName, label) {
const cat = PROMPT_LIBRARY.find(c => c.name === catName);
if (!cat) return;
if (cat.multi) {
if (!multiSelected[catName]) multiSelected[catName] = new Set();
const set = multiSelected[catName];
set.has(label) ? set.delete(label) : set.add(label);
} else {
// 单选:再次点击同一词条则取消,否则替换
selected[catName] = selected[catName] === label ? null : label;
}
renderLibraryTab();
refreshPreview();
}
// ── 插入到节点 ────────────────────────────────────────────────────────────
function insertText(text) {
if (!text || !activeNode) return closePanel();
const widget = resolveTargetWidget(activeNode);
if (!widget) {
alert("未找到可注入的提示词框。");
return;
}
appendToPrompt(activeNode, widget, text);
closePanel();
}
function insertAssembled() {
const text = buildAssembled();
if (!text) { closePanel(); return; }
insertText(text);
}
function resetSelection() {
for (const k of Object.keys(selected)) delete selected[k];
for (const k of Object.keys(multiSelected)) delete multiSelected[k];
}
// ── 面板开关 ──────────────────────────────────────────────────────────────
function closePanel() {
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;
resetSelection();
trackFocus(node);
dialogRoot = document.createElement("div");
dialogRoot.id = DIALOG_ID;
dialogRoot.innerHTML = `
<div id="o1key-plib-modal" role="dialog" aria-modal="true">
<div id="o1key-plib-header">
<div id="o1key-plib-title">提示词词库</div>
<button class="o1pl-icon-btn" data-close title="关闭">${iconClose()}</button>
</div>
<div id="o1pl-body"></div>
<div id="o1pl-footer">
<div id="o1pl-preview"></div>
<div id="o1pl-footer-row">
<span id="o1pl-target"></span>
<button class="o1pl-btn o1pl-btn-ghost" data-reset>清空</button>
<button class="o1pl-btn o1pl-btn-primary" data-insert>插入组合</button>
</div>
</div>
</div>`;
document.body.appendChild(dialogRoot);
dialogRoot.addEventListener("click", (e) => { if (e.target === dialogRoot) closePanel(); });
dialogRoot.querySelector("[data-close]").addEventListener("click", closePanel);
dialogRoot.querySelector("[data-reset]").addEventListener("click", () => { resetSelection(); renderLibraryTab(); refreshPreview(); });
dialogRoot.querySelector("[data-insert]").addEventListener("click", insertAssembled);
document.addEventListener("keydown", handleKeydown);
renderLibraryTab();
refreshPreview();
}
// ── 扩展注册:在目标节点上添加常驻「词库」按钮 widget ──────────────────────
app.registerExtension({
name: "o1key.promptLibrary",
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 });
// 绑定提示词框聚焦追踪(widget 此时可能尚未建好,延迟一拍)
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) });
};
},
});
+99
View File
@@ -0,0 +1,99 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPE = "O1keyPromptMultiFunction";
const MODE_WIDGET = "功能";
const SAMPLE_COUNT_WIDGET = "抽取数量";
const SELECTED_INDICES_WIDGET = "指定序号";
const RANDOM_MODES = new Set(["随机抽取n套", "随机抽取1套", "随机抽取多套"]);
const SELECTED_MODE = "指定序号";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function setWidgetHidden(widget, hidden) {
if (!widget || widget.__o1keyPromptMultiHidden === hidden) return false;
widget.__o1keyPromptMultiHidden = hidden;
widget.hidden = hidden;
widget.options ??= {};
widget.options.hidden = hidden;
if (!("__o1keyPromptMultiOriginalComputeSize" in widget)) {
widget.__o1keyPromptMultiOriginalComputeSize = widget.computeSize ?? null;
}
if (hidden) {
widget.computeSize = () => [0, -4];
} else if (widget.__o1keyPromptMultiOriginalComputeSize) {
widget.computeSize = widget.__o1keyPromptMultiOriginalComputeSize;
} else {
delete widget.computeSize;
}
return true;
}
function syncPromptMultiFunctionWidgets(node) {
const modeWidget = findWidget(node, MODE_WIDGET);
const sampleCountWidget = findWidget(node, SAMPLE_COUNT_WIDGET);
const selectedIndicesWidget = findWidget(node, SELECTED_INDICES_WIDGET);
if (!modeWidget || !sampleCountWidget || !selectedIndicesWidget) return;
const randomMode = RANDOM_MODES.has(modeWidget.value);
const selectedMode = modeWidget.value === SELECTED_MODE;
const countChanged = setWidgetHidden(sampleCountWidget, !randomMode);
const indicesChanged = setWidgetHidden(selectedIndicesWidget, !selectedMode);
if (countChanged || indicesChanged) node.setDirtyCanvas?.(true, true);
}
function scheduleWidgetSync(node) {
syncPromptMultiFunctionWidgets(node);
requestAnimationFrame(() => syncPromptMultiFunctionWidgets(node));
}
function guardNode(node) {
if (node.__o1keyPromptMultiFunctionDynamic) return;
if (!findWidget(node, MODE_WIDGET)
|| !findWidget(node, SAMPLE_COUNT_WIDGET)
|| !findWidget(node, SELECTED_INDICES_WIDGET)) return;
node.__o1keyPromptMultiFunctionDynamic = true;
const bindModeWidget = () => {
const modeWidget = findWidget(node, MODE_WIDGET);
if (!modeWidget || modeWidget.__o1keyPromptMultiFunctionCallback) return;
modeWidget.__o1keyPromptMultiFunctionCallback = true;
const originalCallback = modeWidget.callback;
modeWidget.callback = function () {
const result = originalCallback?.apply(this, arguments);
scheduleWidgetSync(node);
return result;
};
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function () {
const result = originalOnConfigure?.apply(this, arguments);
bindModeWidget();
scheduleWidgetSync(this);
return result;
};
bindModeWidget();
scheduleWidgetSync(node);
}
function isTargetNode(node) {
return node?.comfyClass === NODE_TYPE || node?.type === NODE_TYPE;
}
app.registerExtension({
name: "o1key.promptMultiFunctionDynamic",
nodeCreated(node) {
if (isTargetNode(node)) guardNode(node);
},
loadedGraphNode(node) {
if (!isTargetNode(node)) return;
guardNode(node);
scheduleWidgetSync(node);
},
});
-175
View File
@@ -1,175 +0,0 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "o1key.restartButton",
async setup() {
function inject() {
if (document.querySelector("#o1k-restart-btn") && document.querySelector("#o1k-update-btn")) return;
const allBtns = document.querySelectorAll("button, .p-togglebutton, .side-bar-button");
let logBtn = null;
for (const btn of allBtns) {
const label = (btn.getAttribute("aria-label") || "") + (btn.textContent || "");
if (label.includes("日志") || label.includes("Console") || label.includes("控制台") || label.includes("Logs")) {
logBtn = btn;
break;
}
}
if (!logBtn || !logBtn.parentNode) return;
function makeButton(id, label, title, icon) {
const btn = logBtn.cloneNode(false);
btn.id = id;
btn.setAttribute("aria-label", label);
btn.title = title;
const logStyle = window.getComputedStyle(logBtn);
btn.style.display = "flex";
btn.style.flexDirection = "column";
btn.style.alignItems = "center";
btn.style.justifyContent = "center";
btn.style.gap = logStyle.gap || "4px";
const iconSpan = document.createElement("span");
iconSpan.innerHTML = icon;
const textSpan = document.createElement("span");
textSpan.textContent = label;
btn.append(iconSpan, textSpan);
return btn;
}
let restartBtn = document.querySelector("#o1k-restart-btn");
if (!restartBtn) {
restartBtn = makeButton("o1k-restart-btn", "重启", "重启 ComfyUI",
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>`);
restartBtn.addEventListener("click", async () => {
if (!confirm("确定要重启 ComfyUI 吗?")) return;
restartBtn.style.opacity = "0.5";
restartBtn.style.pointerEvents = "none";
await disableExperimentalAssetApi();
try { await fetch("/o1key/restart", { method: "POST" }); } catch {}
pollUntilReady();
});
logBtn.parentNode.insertBefore(restartBtn, logBtn);
}
if (!document.querySelector("#o1k-update-btn")) {
const updateBtn = makeButton("o1k-update-btn", "更新", "更新 comfyui_o1key 节点包",
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 18v3h16v-3"/></svg>`);
let updating = false;
updateBtn.addEventListener("click", async () => {
if (updating) return;
if (!confirm("从 origin/main 拉取 comfyui_o1key 最新版本?")) return;
updating = true;
updateBtn.disabled = true;
updateBtn.style.opacity = "0.5";
updateBtn.title = "正在更新...";
try {
const response = await fetch("/o1key/update", {
method: "POST",
headers: { "X-O1Key-Update": "1" },
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "更新失败");
if (!result.updated) {
alert(`已是最新版本(${result.version})。`);
} else {
const dependencies = result.requirements_changed
? "\n依赖列表已变化,请先在 ComfyUI 的 Python 环境中执行 pip install -r requirements.txt。"
: "";
alert(`更新完成(${result.version})。${dependencies}\n请点击“重启”使新版本生效。`);
}
} catch (error) {
alert(`更新失败:${error.message}`);
} finally {
updating = false;
updateBtn.disabled = false;
updateBtn.style.opacity = "";
updateBtn.title = "更新 comfyui_o1key 节点包";
}
});
restartBtn.after(updateBtn);
}
}
async function disableExperimentalAssetApi() {
if (!(await shouldDisableExperimentalAssetApi())) return;
try {
await fetch("/api/settings/Comfy.Assets.UseAssetAPI", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(false),
signal: AbortSignal.timeout(2000),
});
} catch {}
}
async function shouldDisableExperimentalAssetApi() {
try {
const r = await fetch("/api/settings/Comfy.Assets.UseAssetAPI", {
cache: "no-store",
signal: AbortSignal.timeout(2000),
});
if (!r.ok || !(await r.json())) return false;
} catch {
return false;
}
return !(await fetchOk("/api/assets/seed/status", 2000));
}
async function fetchOk(url, timeout = 2500) {
try {
const r = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(timeout),
});
return r.ok;
} catch {
return false;
}
}
async function comfyReady() {
const [statsOk, modelFoldersOk] = await Promise.all([
fetchOk("/api/system_stats"),
fetchOk("/api/experiment/models"),
]);
return statsOk && modelFoldersOk;
}
function pollUntilReady() {
let attempts = 0;
const maxAttempts = 80;
const minRestartWaitMs = 5000;
const startedAt = Date.now();
let sawUnavailable = false;
const interval = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) { clearInterval(interval); forceReload(); return; }
const ready = await comfyReady();
if (!ready) {
sawUnavailable = true;
return;
}
if (!sawUnavailable && Date.now() - startedAt < minRestartWaitMs) return;
clearInterval(interval);
await disableExperimentalAssetApi();
setTimeout(forceReload, 800);
}, 1500);
}
function forceReload() {
window.onbeforeunload = null;
Object.defineProperty(BeforeUnloadEvent.prototype, "returnValue", {
get() { return ""; },
set() {}
});
location.reload();
}
const observer = new MutationObserver(inject);
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(inject, 2000);
setTimeout(inject, 4000);
setTimeout(inject, 8000);
},
});
+301
View File
@@ -0,0 +1,301 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPES = new Set(["SeedanceAutoPass", "SeedanceAutoPassBatch"]);
const MODEL_25 = "seedance 2.5";
const OVERSEAS_ROUTE = "海外";
const LEGACY_MODEL_ROUTES = new Set(["海外HC", "海外破限高并发", "海外破限", "海外破限标准", "海外标准"]);
const ALL_RESOLUTIONS = ["480p", "720p", "1080p", "4k"];
const LIMITED_RESOLUTIONS = ["480p", "720p"];
const LIMITED_RESOLUTION_MODELS = new Set([
"seedance 2.0 fast",
"seedance 2.0 mini",
]);
const RESOLUTION_FALLBACK = "720p";
const ID_WIDGET_GROUPS = [
{ prefix: "图片素材ID", maximum: 30 },
{ prefix: "视频素材ID", maximum: 10 },
{ prefix: "音频素材ID", maximum: 10 },
];
const UNAVAILABLE_WIDGETS = ["联网搜索", "返回末帧图片"];
const durationOptions = (maximum) => [
"自动",
...Array.from({ length: maximum - 3 }, (_, index) => `${index + 4}`),
];
const DURATIONS_20 = durationOptions(15);
const DURATIONS_25 = durationOptions(30);
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function findAssetModeWidget(node) {
return findWidget(node, "素材创建模式") ?? findWidget(node, "素材创建");
}
const REFERENCE_GROUPS = ["参考图片", "参考视频", "参考音频"];
const FRAME_INPUTS = ["首帧图片", "尾帧图片"];
function isReferenceInput(input) {
return REFERENCE_GROUPS.some((group) => input.name.startsWith(`${group}.`)
|| new RegExp(`^${group}\\d+$`).test(input.name));
}
function inputOptions(input) {
// Never reuse a removed socket's link or internal identity.
return { label: input.label ?? input.name.split(".").at(-1), shape: input.shape };
}
function syncModeInputs(node) {
const state = node.__o1keyAutoPassModeInputs;
if (!state || state.syncing) return;
const frameMode = findWidget(node, "生成模式")?.value === "首尾帧";
const autogrow = node.comfyDynamic.autogrow;
state.syncing = true;
try {
// Suspend native Autogrow before removing its sockets; otherwise a
// disconnect callback can recreate an inactive reference input.
for (const group of REFERENCE_GROUPS) {
if (frameMode) delete autogrow[group];
else autogrow[group] = state.groups[group];
}
for (let index = node.inputs.length - 1; index >= 0; index--) {
const input = node.inputs[index];
if (frameMode ? isReferenceInput(input) : FRAME_INPUTS.includes(input.name)) {
node.removeInput(index);
}
}
const templates = frameMode ? state.frames : state.references;
for (const template of templates) {
const exists = frameMode
? node.inputs.some((input) => input.name === template.name)
: node.inputs.some((input) => input.name.startsWith(`${template.name.split(".")[0]}.`));
if (!exists) node.addInput(template.name, template.type, inputOptions(template));
}
} finally {
state.syncing = false;
}
syncIdWidgets(node);
}
function guardModeInputs(node) {
if (node.comfyClass !== "SeedanceAutoPass" || node.__o1keyAutoPassModeInputs) return;
const mode = findWidget(node, "生成模式");
const autogrow = node.comfyDynamic?.autogrow;
if (!mode || !autogrow || !node.addInput || !node.removeInput) return;
const references = REFERENCE_GROUPS.map((group) => node.inputs?.find((input) => input.name.startsWith(`${group}.`)));
if (references.some((input) => !input) || REFERENCE_GROUPS.some((group) => !autogrow[group])) return;
node.__o1keyAutoPassModeInputs = {
syncing: false,
groups: Object.fromEntries(REFERENCE_GROUPS.map((group) => [group, autogrow[group]])),
references: references.map((input) => ({ ...input })),
frames: FRAME_INPUTS.map((name) => ({ ...(node.inputs.find((input) => input.name === name) ?? { name, type: "IMAGE" }) })),
};
const originalConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function () {
if (node.__o1keyAutoPassModeInputs.syncing) return;
return originalConnectionsChange?.apply(this, arguments);
};
const originalCallback = mode.callback;
mode.callback = function () {
const result = originalCallback?.apply(this, arguments);
syncModeInputs(node);
return result;
};
syncModeInputs(node);
}
// 与 SeedanceMultiModal 相同:保留控件和值的序列化顺序,仅渐进显隐单行 ID。
function setIdWidgetHidden(widget, hidden) {
if (!widget || (widget.__o1keyAutoPassIdHidden === hidden
&& widget.hidden === hidden && widget.options?.hidden === hidden)) return;
widget.__o1keyAutoPassIdHidden = hidden;
widget.hidden = hidden;
widget.options ??= {};
widget.options.hidden = hidden;
if (!("__o1keyAutoPassOriginalComputeSize" in widget)) {
widget.__o1keyAutoPassOriginalComputeSize = widget.computeSize ?? null;
}
if (hidden) {
widget.computeSize = () => [0, -4];
} else if (widget.__o1keyAutoPassOriginalComputeSize) {
widget.computeSize = widget.__o1keyAutoPassOriginalComputeSize;
} else {
delete widget.computeSize;
}
}
function syncIdWidgets(node) {
if (node.comfyClass !== "SeedanceAutoPass") return;
// Temporarily unavailable controls stay serialized for saved workflows.
for (const name of UNAVAILABLE_WIDGETS) setIdWidgetHidden(findWidget(node, name), true);
const assetMode = findAssetModeWidget(node);
// 该开关自身始终显示,只控制下方 ID 行。
if (assetMode) {
assetMode.hidden = false;
assetMode.options ??= {};
assetMode.options.hidden = false;
}
const manual = ["打开", "手动", "manual"].includes(assetMode?.value);
for (const { prefix, maximum } of ID_WIDGET_GROUPS) {
const widgets = Array.from({ length: maximum }, (_, index) => findWidget(node, `${prefix}${index + 1}`));
let highestFilled = -1;
widgets.forEach((widget, index) => {
if (typeof widget?.value === "string" && widget.value.trim()) highestFilled = index;
});
const visibleCount = manual ? Math.min(maximum, Math.max(1, highestFilled + 2)) : 0;
widgets.forEach((widget, index) => setIdWidgetHidden(widget, index >= visibleCount));
}
if (typeof node.computeSize === "function" && typeof node.setSize === "function") {
const size = node.computeSize([...node.size]);
node.setSize([node.size[0], size[1]]);
}
node.setDirtyCanvas?.(true, true);
}
function guardIdWidgets(node) {
if (node.comfyClass !== "SeedanceAutoPass" || node.__o1keyAutoPassIdGuard) return;
node.__o1keyAutoPassIdGuard = true;
const widgets = [findAssetModeWidget(node)];
for (const { prefix, maximum } of ID_WIDGET_GROUPS) {
for (let index = 1; index <= maximum; index++) widgets.push(findWidget(node, `${prefix}${index}`));
}
for (const widget of widgets) {
if (!widget) continue;
const originalCallback = widget.callback;
widget.callback = function () {
const result = originalCallback?.apply(this, arguments);
syncIdWidgets(node);
return result;
};
}
syncIdWidgets(node);
}
function notify(detail) {
app.extensionManager?.toast?.add({
severity: "info",
summary: "Seedance 全能生成视频参数已调整",
detail,
life: 3500,
});
}
function setWidgetValue(widget, value) {
if (!widget || widget.value === value) return;
widget.value = value;
widget.callback?.(value);
}
function updateDynamicParameters(node, silent = false) {
const modelWidget = findWidget(node, "主模型");
const routeWidget = findWidget(node, "模型线路");
const resolutionWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !routeWidget || !resolutionWidget || !durationWidget) return;
const is25 = modelWidget.value === MODEL_25;
const hasLimitedResolution = LIMITED_RESOLUTION_MODELS.has(modelWidget.value);
const maximumDuration = is25 ? 30 : 15;
durationWidget.options ??= {};
durationWidget.options.values = is25 ? DURATIONS_25 : DURATIONS_20;
resolutionWidget.options ??= {};
resolutionWidget.options.values = hasLimitedResolution ? LIMITED_RESOLUTIONS : ALL_RESOLUTIONS;
if (LEGACY_MODEL_ROUTES.has(routeWidget.value)) {
setWidgetValue(routeWidget, OVERSEAS_ROUTE);
}
if (hasLimitedResolution && !LIMITED_RESOLUTIONS.includes(resolutionWidget.value)) {
setWidgetValue(resolutionWidget, RESOLUTION_FALLBACK);
if (!silent) {
notify(`${modelWidget.value} 仅支持 480p/720p,已自动切换为 ${RESOLUTION_FALLBACK}`);
}
}
const duration = Number.parseInt(durationWidget.value, 10);
if (Number.isFinite(duration) && duration > maximumDuration) {
setWidgetValue(durationWidget, `${maximumDuration}`);
if (!silent) {
notify(`${modelWidget.value} 的最大时长为 ${maximumDuration} 秒,已自动调整。`);
}
}
node.setDirtyCanvas?.(true, true);
}
function guardNode(node) {
if (node.__o1keySeedanceAutoPassDynamic) return;
node.__o1keySeedanceAutoPassDynamic = true;
const modelWidget = findWidget(node, "主模型");
const routeWidget = findWidget(node, "模型线路");
const resolutionWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !routeWidget || !resolutionWidget || !durationWidget) return;
const originalModelCallback = modelWidget.callback;
modelWidget.callback = function (value) {
const result = originalModelCallback?.apply(this, arguments);
updateDynamicParameters(node);
return result;
};
const originalRouteCallback = routeWidget.callback;
routeWidget.callback = function (value) {
if (LEGACY_MODEL_ROUTES.has(value)) {
routeWidget.value = OVERSEAS_ROUTE;
node.setDirtyCanvas?.(true, true);
return originalRouteCallback?.call(this, OVERSEAS_ROUTE);
}
return originalRouteCallback?.apply(this, arguments);
};
const originalResolutionCallback = resolutionWidget.callback;
resolutionWidget.callback = function (value) {
if (LIMITED_RESOLUTION_MODELS.has(modelWidget.value) && !LIMITED_RESOLUTIONS.includes(value)) {
resolutionWidget.value = RESOLUTION_FALLBACK;
notify(`${modelWidget.value} 仅支持 480p/720p。`);
node.setDirtyCanvas?.(true, true);
return originalResolutionCallback?.call(this, RESOLUTION_FALLBACK);
}
return originalResolutionCallback?.apply(this, arguments);
};
const originalDurationCallback = durationWidget.callback;
durationWidget.callback = function (value) {
const maximumDuration = modelWidget.value === MODEL_25 ? 30 : 15;
const duration = Number.parseInt(value, 10);
if (Number.isFinite(duration) && duration > maximumDuration) {
const fallback = `${maximumDuration}`;
durationWidget.value = fallback;
notify(`${modelWidget.value} 的最大时长为 ${maximumDuration} 秒。`);
node.setDirtyCanvas?.(true, true);
return originalDurationCallback?.call(this, fallback);
}
return originalDurationCallback?.apply(this, arguments);
};
updateDynamicParameters(node, true);
}
app.registerExtension({
name: "o1key.seedanceAutoPassDynamic",
nodeCreated(node) {
if (!NODE_TYPES.has(node.comfyClass)) return;
guardNode(node);
guardIdWidgets(node);
guardModeInputs(node);
},
loadedGraphNode(node) {
if (!NODE_TYPES.has(node.comfyClass)) return;
updateDynamicParameters(node, true);
guardIdWidgets(node);
guardModeInputs(node);
syncModeInputs(node);
syncIdWidgets(node);
},
});
+95
View File
@@ -0,0 +1,95 @@
import { app } from "../../../scripts/app.js";
const NODE_TYPE = "SeedanceMultiModal";
const ID_WIDGET_GROUPS = [
{ prefix: "图片素材ID", maximum: 30 },
{ prefix: "视频素材ID", maximum: 10 },
{ prefix: "音频素材ID", maximum: 10 },
];
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hasValue(widget) {
return typeof widget?.value === "string"
? widget.value.trim().length > 0
: widget?.value != null;
}
function setWidgetHidden(widget, hidden) {
if (!widget || widget.__o1keyProgressiveHidden === hidden) return;
widget.__o1keyProgressiveHidden = hidden;
widget.hidden = hidden;
widget.options ??= {};
widget.options.hidden = hidden;
if (!("__o1keyOriginalComputeSize" in widget)) {
widget.__o1keyOriginalComputeSize = widget.computeSize ?? null;
}
if (hidden) {
widget.computeSize = () => [0, -4];
} else if (widget.__o1keyOriginalComputeSize) {
widget.computeSize = widget.__o1keyOriginalComputeSize;
} else {
delete widget.computeSize;
}
}
function resizeNodeToVisibleWidgets(node) {
if (typeof node.computeSize !== "function" || typeof node.setSize !== "function") return;
const computedSize = node.computeSize([...node.size]);
node.setSize([node.size[0], computedSize[1]]);
}
function syncProgressiveIdWidgets(node) {
for (const { prefix, maximum } of ID_WIDGET_GROUPS) {
const widgets = Array.from(
{ length: maximum },
(_, index) => findWidget(node, `${prefix}${index + 1}`),
);
let highestFilled = -1;
widgets.forEach((widget, index) => {
if (hasValue(widget)) highestFilled = index;
});
const visibleCount = Math.min(maximum, Math.max(1, highestFilled + 2));
widgets.forEach((widget, index) => setWidgetHidden(widget, index >= visibleCount));
}
resizeNodeToVisibleWidgets(node);
node.setDirtyCanvas?.(true, true);
}
function guardNode(node) {
if (node.__o1keySeedanceMultiModalDynamic) return;
node.__o1keySeedanceMultiModalDynamic = true;
for (const { prefix, maximum } of ID_WIDGET_GROUPS) {
for (let index = 1; index <= maximum; index++) {
const widget = findWidget(node, `${prefix}${index}`);
if (!widget || widget.__o1keyProgressiveCallback) continue;
widget.__o1keyProgressiveCallback = true;
const originalCallback = widget.callback;
widget.callback = function () {
const result = originalCallback?.apply(this, arguments);
syncProgressiveIdWidgets(node);
return result;
};
}
}
syncProgressiveIdWidgets(node);
}
app.registerExtension({
name: "o1key.seedanceMultiModalDynamic",
nodeCreated(node) {
if (node.comfyClass === NODE_TYPE) guardNode(node);
},
loadedGraphNode(node) {
if (node.comfyClass !== NODE_TYPE) return;
guardNode(node);
syncProgressiveIdWidgets(node);
},
});
+137
View File
@@ -0,0 +1,137 @@
import { app } from "../../../scripts/app.js";
// ── Seedance 多模态节点参数联动 ───────────────────────────────────────────────
//
// fast、mini 不支持 1080p / 4k2.5 支持全部分辨率和最长 30 秒。
// 当用户切换到受限模型,或选择不支持的分辨率时,
// 弹 toast 报错并把分辨率回退到 720p,避免提交后才在后端报错浪费一次调用。
//
// 注:「模型」下拉存的是展示名(seedance 2.0 fast 等),非真实模型 ID。
const LIMITED_MODELS = new Set([
"seedance 2.0 fast",
"seedance 2.0 mini",
]);
const isLimitedModel = (v) => LIMITED_MODELS.has(v);
const MODEL_25 = "seedance 2.5";
const ALL_RESOLUTIONS = ["720p", "1080p", "4k", "480p"];
const LIMITED_RESOLUTIONS = ["720p", "480p"];
const RESOLUTION_FALLBACK = "720p";
const durationOptions = (maximum) => [
"自动",
...Array.from({ length: maximum - 3 }, (_, index) => `${index + 4}`),
];
const DURATIONS_20 = durationOptions(15);
const DURATIONS_25 = durationOptions(30);
const SEEDANCE_NODES = new Set(["SeedanceMultiModal"]);
function notify(detail) {
app.extensionManager?.toast?.add({
severity: "warn",
summary: "Seedance 多模态参数已调整",
detail,
life: 4000,
});
}
function findWidget(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function setWidgetValue(widget, value) {
if (!widget || widget.value === value) return;
widget.value = value;
widget.callback?.(value);
}
function updateDynamicParameters(node, silent = false) {
const modelWidget = findWidget(node, "主模型");
const resWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !resWidget || !durationWidget) return;
const limited = isLimitedModel(modelWidget.value);
const maximumDuration = modelWidget.value === MODEL_25 ? 30 : 15;
resWidget.options ??= {};
resWidget.options.values = limited ? LIMITED_RESOLUTIONS : ALL_RESOLUTIONS;
durationWidget.options ??= {};
durationWidget.options.values = maximumDuration === 30 ? DURATIONS_25 : DURATIONS_20;
if (limited && !LIMITED_RESOLUTIONS.includes(resWidget.value)) {
setWidgetValue(resWidget, RESOLUTION_FALLBACK);
if (!silent) {
notify(`${modelWidget.value} 仅支持 480p/720p,已自动切换为 ${RESOLUTION_FALLBACK}`);
}
}
const duration = Number.parseInt(durationWidget.value, 10);
if (Number.isFinite(duration) && duration > maximumDuration) {
setWidgetValue(durationWidget, `${maximumDuration}`);
if (!silent) {
notify(`${modelWidget.value} 的最大时长为 ${maximumDuration} 秒,已自动调整。`);
}
}
node.setDirtyCanvas?.(true, true);
}
function guardNode(node) {
if (node.__o1keySeedanceParameterGuard) return;
node.__o1keySeedanceParameterGuard = true;
const modelWidget = findWidget(node, "主模型");
const resWidget = findWidget(node, "分辨率");
const durationWidget = findWidget(node, "时长");
if (!modelWidget || !resWidget || !durationWidget) return;
const origModelCb = modelWidget.callback;
modelWidget.callback = function (value) {
const ret = origModelCb?.apply(this, arguments);
updateDynamicParameters(node);
return ret;
};
const origResCb = resWidget.callback;
resWidget.callback = function (value) {
if (isLimitedModel(modelWidget.value) && !LIMITED_RESOLUTIONS.includes(value)) {
resWidget.value = RESOLUTION_FALLBACK;
notify(`${modelWidget.value} 仅支持 480p/720p。`);
node.setDirtyCanvas?.(true, true);
return origResCb?.call(this, RESOLUTION_FALLBACK);
}
return origResCb?.apply(this, arguments);
};
const origDurationCb = durationWidget.callback;
durationWidget.callback = function (value) {
const maximumDuration = modelWidget.value === MODEL_25 ? 30 : 15;
const duration = Number.parseInt(value, 10);
if (Number.isFinite(duration) && duration > maximumDuration) {
const fallback = `${maximumDuration}`;
durationWidget.value = fallback;
notify(`${modelWidget.value} 的最大时长为 ${maximumDuration} 秒。`);
node.setDirtyCanvas?.(true, true);
return origDurationCb?.call(this, fallback);
}
return origDurationCb?.apply(this, arguments);
};
updateDynamicParameters(node, true);
}
app.registerExtension({
name: "o1key.seedanceResolutionGuard",
nodeCreated(node) {
if (SEEDANCE_NODES.has(node.comfyClass)) {
guardNode(node);
}
},
loadedGraphNode(node) {
if (!SEEDANCE_NODES.has(node.comfyClass)) return;
guardNode(node);
updateDynamicParameters(node, true);
},
});
-90
View File
@@ -1,90 +0,0 @@
import { api } from "../../../scripts/api.js";
function startCountdown(toast, closeBtn, seconds, accentColor) {
let remaining = seconds;
closeBtn.textContent = `× ${remaining}s`;
const interval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(interval);
toast.style.transition = "opacity 0.4s ease";
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 400);
} else {
closeBtn.textContent = `× ${remaining}s`;
}
}, 1000);
closeBtn.onclick = () => {
clearInterval(interval);
toast.remove();
};
}
api.addEventListener("o1key.new_version", (event) => {
const changelog = event.detail?.changelog || [];
const style = document.createElement("style");
style.textContent = `
@keyframes o1key-slide-in {
from { opacity: 0; transform: translateY(16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`;
document.head.appendChild(style);
const toast = document.createElement("div");
toast.style.cssText = `
position: fixed;
bottom: 28px;
left: 28px;
background: linear-gradient(135deg, #0a1628 0%, #0d2b4e 60%, #1a4a7a 100%);
color: #d6eaf8;
border: 1px solid #2e86c1;
border-radius: 12px;
padding: 12px 16px;
font-size: 14px;
z-index: 100000;
box-shadow: 0 6px 24px rgba(46,134,193,0.35), 0 2px 8px rgba(0,0,0,0.5);
max-width: 340px;
animation: o1key-slide-in 0.4s cubic-bezier(.22,.68,0,1.2);
`;
const header = document.createElement("div");
header.style.cssText = `display: flex; align-items: center; justify-content: space-between;`;
const title = document.createElement("span");
title.textContent = "🔔 检测到有新版本发布!";
title.style.cssText = `font-weight: bold; font-size: 13px; color: #7fb3d3; letter-spacing: 0.5px;`;
const closeBtn = document.createElement("button");
closeBtn.style.cssText = `background: none; border: none; color: #7fb3d3; font-size: 13px; cursor: pointer; padding: 0; line-height: 1;`;
header.appendChild(title);
header.appendChild(closeBtn);
const divider = document.createElement("div");
divider.style.cssText = `height: 1px; background: rgba(46,134,193,0.3); margin: 8px 0;`;
toast.appendChild(header);
toast.appendChild(divider);
const body = document.createElement("div");
const items = changelog.length > 0 ? changelog : ["暂无更新说明"];
items.forEach(item => {
const line = document.createElement("div");
line.textContent = `${item}`;
line.style.cssText = `margin-bottom: 4px; font-size: 12px; line-height: 1.6; color: #d6eaf8;`;
body.appendChild(line);
});
const more = document.createElement("div");
more.textContent = "...";
more.style.cssText = `color: #7fb3d3; font-size: 12px; margin-top: 2px;`;
body.appendChild(more);
toast.appendChild(body);
document.body.appendChild(toast);
startCountdown(toast, closeBtn, 5, "#7fb3d3");
});
+524
View File
@@ -0,0 +1,524 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// ── 上传本地视频到 ComfyUI input 目录,返回 {name, subfolder, type} ──────────────
async function uploadVideo(file) {
const formData = new FormData();
formData.append("image", file, file.name); // /upload/image 接受任意文件,按字节写入
const resp = await api.fetchApi("/upload/image", { method: "POST", body: formData });
if (!resp.ok) throw new Error(`上传失败: ${file.name}`);
return await resp.json(); // {name, subfolder, type:"input"}
}
let _inputDir = null;
async function getInputDir() {
if (_inputDir !== null) return _inputDir;
try {
const resp = await api.fetchApi("/o1key/input_dir");
_inputDir = resp.ok ? (await resp.json()).path : "";
} catch { _inputDir = ""; }
return _inputDir;
}
function fmtTime(s) {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
const cs = Math.floor((s - Math.floor(s)) * 10); // 0.1s 精度
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${cs}`;
}
function inputVideoDescriptor(rawPath, inputDir) {
const path = String(rawPath ?? "").trim().replace(/^(["'])(.*)\1$/, "$2").replace(/\\/g, "/");
if (!path) return null;
const root = String(inputDir ?? "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
const isAbsolute = /^[A-Za-z]:\//.test(path) || path.startsWith("/");
let relative = path.replace(/^\/+/, "");
if (isAbsolute) {
if (!root) return null;
const pathLower = path.toLowerCase();
const rootLower = root.toLowerCase();
if (!pathLower.startsWith(`${rootLower}/`)) return null;
relative = path.slice(root.length + 1);
}
const parts = relative.split("/").filter(Boolean);
if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
const filename = parts.pop();
return { filename, subfolder: parts.join("/"), type: "input" };
}
function videoViewUrl(descriptor) {
const params = new URLSearchParams({
filename: descriptor.filename,
type: descriptor.type || "input",
});
if (descriptor.subfolder) params.set("subfolder", descriptor.subfolder);
return api.apiURL(`/view?${params.toString()}`);
}
function hideInternalPathWidget(widget) {
if (!widget) return;
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
}
const CSS = `
.o1vt-wrap{width:100%;display:flex;flex-direction:column;gap:6px;padding:6px;box-sizing:border-box;font-size:12px;color:#ddd;}
.o1vt-btn{width:100%;padding:6px 8px;cursor:pointer;background:#3a5a3a;color:#eee;border:1px solid #5c805c;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:6px;}
.o1vt-btn:hover{background:#456b45;}
.o1vt-btn svg{width:16px;height:16px;flex:0 0 auto;}
.o1vt-stage{position:relative;width:100%;background:#000;border-radius:4px;overflow:hidden;display:flex;align-items:center;justify-content:center;min-height:120px;}
.o1vt-stage video{width:100%;display:block;object-fit:contain;background:#000;}
.o1vt-empty{color:#888;font-size:12px;padding:30px 8px;text-align:center;line-height:1.6;}
.o1vt-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.4);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;}
.o1vt-play:hover{background:rgba(0,0,0,.75);}
.o1vt-times{display:flex;justify-content:space-between;color:#9adf9a;font-variant-numeric:tabular-nums;}
.o1vt-track{position:relative;width:100%;height:54px;background:#1a1a1a;border-radius:4px;overflow:hidden;user-select:none;touch-action:none;}
.o1vt-film{position:absolute;inset:0;display:flex;background:#222;}
.o1vt-film img{height:100%;flex:1 1 0;min-width:0;object-fit:cover;pointer-events:none;display:block;}
.o1vt-dim{position:absolute;top:0;bottom:0;background:rgba(0,0,0,.62);pointer-events:none;}
.o1vt-sel{position:absolute;top:0;bottom:0;border:2px solid #4caf50;box-sizing:border-box;border-radius:3px;cursor:grab;background:rgba(76,175,80,.08);}
.o1vt-sel.grabbing{cursor:grabbing;}
.o1vt-handle{position:absolute;top:0;bottom:0;width:12px;background:#4caf50;cursor:ew-resize;display:flex;align-items:center;justify-content:center;z-index:3;}
.o1vt-handle::after{content:"";width:2px;height:18px;background:rgba(0,0,0,.55);border-radius:1px;}
.o1vt-handle.l{border-radius:3px 0 0 3px;}
.o1vt-handle.r{border-radius:0 3px 3px 0;}
.o1vt-playhead{position:absolute;top:0;bottom:0;width:2px;background:#fff;pointer-events:none;box-shadow:0 0 3px rgba(0,0,0,.8);z-index:4;}
.o1vt-info{text-align:center;color:#aaa;}
.o1vt-info b{color:#9adf9a;}
`;
// 视频图标(svg
const VIDEO_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="14" height="16" rx="2"/><path d="M16 9l5-3v12l-5-3"/></svg>`;
function injectCSS() {
if (document.getElementById("o1vt-style")) return;
const s = document.createElement("style");
s.id = "o1vt-style";
s.textContent = CSS;
document.head.appendChild(s);
}
// 抽帧缩略图(同源视频,canvas 不会被污染)。等待真实解码帧后再绘制,避免黑帧。
async function buildFilmstrip(url, count, filmEl, token, isCurrent) {
const v = document.createElement("video");
v.muted = true;
v.preload = "auto";
v.crossOrigin = "anonymous";
v.src = url;
try {
await new Promise((res, rej) => {
v.addEventListener("loadeddata", res, { once: true });
v.addEventListener("error", () => rej(new Error("video load error")), { once: true });
});
} catch { return; }
const dur = v.duration;
if (!isFinite(dur) || dur <= 0) return;
const w = 96;
const ratio = (v.videoHeight && v.videoWidth) ? v.videoHeight / v.videoWidth : 0.56;
const h = Math.max(1, Math.round(w * ratio));
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
const grabAt = (t) => new Promise((resolve) => {
let settled = false;
const done = () => { if (!settled) { settled = true; resolve(); } };
const onSeeked = () => {
// 等一帧真正解码完成再画,否则可能拿到黑帧
if (v.requestVideoFrameCallback) {
v.requestVideoFrameCallback(() => done());
} else {
setTimeout(done, 70);
}
};
v.addEventListener("seeked", onSeeked, { once: true });
v.addEventListener("error", done, { once: true });
try { v.currentTime = Math.min(Math.max(t, 0), Math.max(dur - 0.04, 0)); }
catch { done(); }
});
const imgs = [];
for (let i = 0; i < count; i++) {
if (!isCurrent(token)) return; // 期间换了视频,放弃
await grabAt((dur * (i + 0.5)) / count);
try { ctx.drawImage(v, 0, 0, w, h); } catch { break; }
const img = document.createElement("img");
img.src = canvas.toDataURL("image/jpeg", 0.6);
imgs.push(img);
}
if (!isCurrent(token)) return;
filmEl.replaceChildren(...imgs);
v.removeAttribute("src");
v.load();
}
app.registerExtension({
name: "o1key.videoTrim",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "O1keyVideoTrim") return;
injectCSS();
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.call(this);
const node = this;
const pathW = node.widgets?.find(w => w.name === "视频路径");
const startW = node.widgets?.find(w => w.name === "开始时间");
const fixedW = node.widgets?.find(w => w.name === "固定时长");
const endW = node.widgets?.find(w => w.name === "结束时间");
// 路径只承载上传结果和工作流恢复,不作为用户输入控件显示。
// options.hidden 是 Nodes 2.0 的正式隐藏标记;不要再写入负高度。
hideInternalPathWidget(pathW);
// 内部状态
let duration = 0; // 视频总时长
let startT = 0, endT = 0; // 当前选区
let dragging = null; // "l" | "r" | "move" | "seek"
let dragOffset = 0; // move 模式:按下点相对窗口起点的时间偏移
let filmToken = 0; // 抽帧任务标识(换视频时作废旧任务)
const fixedDur = () => Math.max(0, +(fixedW?.value) || 0);
// ── DOM 结构 ──────────────────────────────────────────────
const wrap = document.createElement("div");
wrap.className = "o1vt-wrap";
const upBtn = document.createElement("button");
upBtn.className = "o1vt-btn";
upBtn.innerHTML = `${VIDEO_ICON}<span>上传视频</span>`;
const upLabel = upBtn.querySelector("span");
const stage = document.createElement("div");
stage.className = "o1vt-stage";
const videoEl = document.createElement("video");
videoEl.muted = true; videoEl.playsInline = true; videoEl.loop = false;
videoEl.crossOrigin = "anonymous";
const emptyEl = document.createElement("div");
emptyEl.className = "o1vt-empty";
emptyEl.textContent = "点上方「上传视频」,拖动绿色窗口裁剪选段";
const playBtn = document.createElement("button");
playBtn.className = "o1vt-play";
playBtn.textContent = "▶";
playBtn.style.display = "none";
stage.append(emptyEl, videoEl, playBtn);
videoEl.style.display = "none";
const timesEl = document.createElement("div");
timesEl.className = "o1vt-times";
const tCur = document.createElement("span");
const tDur = document.createElement("span");
tCur.textContent = "00:00.0"; tDur.textContent = "00:00.0";
timesEl.append(tCur, tDur);
const track = document.createElement("div");
track.className = "o1vt-track";
const film = document.createElement("div"); film.className = "o1vt-film";
const dimL = document.createElement("div"); dimL.className = "o1vt-dim";
const dimR = document.createElement("div"); dimR.className = "o1vt-dim";
const sel = document.createElement("div"); sel.className = "o1vt-sel";
const hL = document.createElement("div"); hL.className = "o1vt-handle l";
const hR = document.createElement("div"); hR.className = "o1vt-handle r";
const phead = document.createElement("div"); phead.className = "o1vt-playhead";
track.append(film, dimL, dimR, sel, hL, hR, phead);
const info = document.createElement("div");
info.className = "o1vt-info";
wrap.append(upBtn, stage, timesEl, track, info);
const editorWidget = node.addDOMWidget("o1vt_editor", "div", wrap, { serialize: false, hideOnZoom: false });
// 让 LiteGraph 布局时把编辑器高度算进节点:computeSize 返回 [宽, 编辑器内容高]。
// 否则节点初始高度不含 DOM widget,内容会溢出到节点框外。
editorWidget.computeSize = function (width) {
const w = width ?? node.size?.[0] ?? 300;
const aspect = node._o1vtAspect || 0.56;
const videoH = Math.round((w - 24) * aspect);
const chromeH = 38 + 18 + 54 + 22 + 30; // 上传按钮+时间+轨道+信息+间距
return [w, videoH + chromeH];
};
// ── 选区计算 / 渲染 / 回写 ─────────────────────────────────
function clampSel() {
if (duration <= 0) return;
const fx = fixedDur();
if (fx > 0) {
const win = Math.min(fx, duration);
startT = Math.max(0, Math.min(startT, duration - win));
endT = startT + win;
} else {
startT = Math.max(0, Math.min(startT, duration));
endT = Math.max(0, Math.min(endT, duration));
if (endT <= startT) endT = Math.min(duration, startT + 0.1);
}
}
function renderSel() {
const pctS = duration > 0 ? (startT / duration) * 100 : 0;
const pctE = duration > 0 ? (endT / duration) * 100 : 100;
dimL.style.left = "0"; dimL.style.width = pctS + "%";
dimR.style.left = pctE + "%"; dimR.style.width = (100 - pctE) + "%";
sel.style.left = pctS + "%"; sel.style.width = Math.max(0, pctE - pctS) + "%";
hL.style.left = `calc(${pctS}% - 6px)`;
hR.style.left = `calc(${pctE}% - 6px)`;
// 固定时长模式隐藏两端手柄(整体拖动窗口)
const fixed = fixedDur() > 0;
hL.style.display = fixed ? "none" : "flex";
hR.style.display = fixed ? "none" : "flex";
info.innerHTML = `选段 <b>${fmtTime(startT)}</b> → <b>${fmtTime(endT)}</b> 时长 <b>${(endT - startT).toFixed(1)}s</b>`;
}
function writeWidgets() {
if (startW) { startW.value = +startT.toFixed(2); }
if (endW && fixedDur() <= 0) { endW.value = +endT.toFixed(2); }
node.setDirtyCanvas(true, true);
}
function renderPlayhead() {
const cur = videoEl.currentTime || 0;
phead.style.left = (duration > 0 ? (cur / duration) * 100 : 0) + "%";
tCur.textContent = fmtTime(cur);
}
// 选区初始化:依据 widget 既有值或固定时长,并立即回写(修复“输出仍是原长”)
function initSelection() {
const fx = fixedDur();
if (fx > 0) {
startT = Math.max(0, Math.min(+(startW?.value) || 0, Math.max(duration - fx, 0)));
endT = startT + Math.min(fx, duration);
} else {
const ws = +(startW?.value) || 0;
const we = +(endW?.value) || 0;
startT = (ws > 0 && ws < duration) ? ws : 0;
endT = (we > 0 && we <= duration) ? we : duration;
}
clampSel(); renderSel(); renderPlayhead(); writeWidgets();
}
// ── 加载视频 ──────────────────────────────────────────────
function loadVideo(url) {
videoEl.src = url;
videoEl.style.display = "block";
emptyEl.style.display = "none";
playBtn.style.display = "flex";
film.replaceChildren();
filmToken++;
}
function clearVideo(message) {
videoEl.pause?.();
videoEl.removeAttribute("src");
videoEl.load?.();
videoEl.style.display = "none";
emptyEl.style.display = "block";
emptyEl.textContent = message;
playBtn.style.display = "none";
film.replaceChildren();
duration = 0; startT = 0; endT = 0;
tCur.textContent = "00:00.0"; tDur.textContent = "00:00.0";
filmToken++;
renderSel();
}
let pathPreviewToken = 0;
async function refreshPathPreview(value = pathW?.value) {
const token = ++pathPreviewToken;
const path = String(value ?? "").trim();
if (!path) {
upLabel.textContent = "上传视频";
clearVideo("点上方「上传视频」,拖动绿色窗口裁剪选段");
return;
}
const inputDir = await getInputDir();
if (token !== pathPreviewToken) return;
const descriptor = inputVideoDescriptor(path, inputDir);
if (!descriptor) {
upLabel.textContent = "重新上传";
clearVideo("已填写视频路径;input 目录外的视频将在执行时读取,画布内不预览");
return;
}
loadVideo(videoViewUrl(descriptor));
upLabel.textContent = "重新上传";
}
node._o1vtRefreshPath = refreshPathPreview;
videoEl.addEventListener("loadedmetadata", () => {
duration = videoEl.duration || 0;
tDur.textContent = fmtTime(duration);
node._o1vtAspect = videoEl.videoWidth ? videoEl.videoHeight / videoEl.videoWidth : 0.56;
node._o1vtResize?.();
initSelection();
const myToken = filmToken;
buildFilmstrip(videoEl.currentSrc || videoEl.src, 10, film, myToken,
(t) => t === filmToken && duration > 0).catch(() => {});
});
videoEl.addEventListener("timeupdate", () => {
renderPlayhead();
if (endT > 0 && videoEl.currentTime >= endT - 0.02) videoEl.pause();
});
videoEl.addEventListener("play", () => { playBtn.textContent = "⏸"; });
videoEl.addEventListener("pause", () => { playBtn.textContent = "▶"; });
playBtn.addEventListener("click", () => {
if (videoEl.paused) {
if (videoEl.currentTime < startT || videoEl.currentTime >= endT - 0.02) videoEl.currentTime = startT;
videoEl.play();
} else videoEl.pause();
});
// ── 时间轴拖拽 ────────────────────────────────────────────
function xToTime(clientX) {
const r = track.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
}
function startDrag(e, mode) {
if (duration <= 0) return;
dragging = mode;
if (mode === "move") { dragOffset = xToTime(e.clientX) - startT; sel.classList.add("grabbing"); }
track.setPointerCapture?.(e.pointerId);
e.preventDefault(); e.stopPropagation();
moveDrag(e);
}
function moveDrag(e) {
if (!dragging || duration <= 0) return;
const t = xToTime(e.clientX);
const fx = fixedDur();
if (dragging === "l") {
startT = Math.min(t, endT - 0.1);
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else if (dragging === "r") {
endT = Math.max(t, startT + 0.1);
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = Math.max(startT, endT - 0.05);
} else if (dragging === "move") {
const win = endT - startT;
startT = t - dragOffset;
endT = startT + win;
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else if (dragging === "seek") {
if (fx > 0) { // 固定时长:在轨道点按 = 把窗口起点移到此处
startT = t; clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else {
videoEl.currentTime = t; renderPlayhead();
}
}
}
function endDrag(e) {
if (!dragging) return;
dragging = null;
sel.classList.remove("grabbing");
try { track.releasePointerCapture?.(e.pointerId); } catch {}
}
hL.addEventListener("pointerdown", e => startDrag(e, "l"));
hR.addEventListener("pointerdown", e => startDrag(e, "r"));
sel.addEventListener("pointerdown", e => startDrag(e, "move"));
track.addEventListener("pointerdown", e => {
if (e.target === hL || e.target === hR || e.target === sel) return;
startDrag(e, "seek");
});
track.addEventListener("pointermove", moveDrag);
track.addEventListener("pointerup", endDrag);
track.addEventListener("pointercancel", endDrag);
// 数字 widget 手改 → 回写时间轴
function syncFromWidgets() {
if (duration <= 0) { renderSel(); return; }
const fx = fixedDur();
if (fx > 0) {
startT = +(startW?.value) || 0;
} else {
startT = +(startW?.value) || 0;
endT = (+(endW?.value) || 0) || duration;
}
clampSel(); renderSel();
}
for (const w of [startW, endW, fixedW]) {
if (!w) continue;
const orig = w.callback;
w.callback = function () {
const result = orig?.apply(this, arguments);
syncFromWidgets();
return result;
};
}
// ── 上传 ──────────────────────────────────────────────────
const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.accept = "video/*"; fileInput.style.display = "none";
document.body.appendChild(fileInput);
upBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) return;
upLabel.textContent = "上传中…"; upBtn.disabled = true;
try {
const info = await uploadVideo(file);
const dir = await getInputDir();
const relativePath = [info.subfolder, info.name].filter(Boolean).join("/");
const absPath = dir ? `${dir.replace(/\\/g, "/").replace(/\/+$/, "")}/${relativePath}` : relativePath;
if (pathW) pathW.value = absPath;
pathPreviewToken++;
duration = 0; startT = 0; endT = 0;
loadVideo(videoViewUrl({
filename: info.name,
subfolder: info.subfolder || "",
type: info.type || "input",
}));
upLabel.textContent = "重新上传";
} catch (err) {
console.error("[o1key videoTrim]", err);
upLabel.textContent = "上传失败,重试";
} finally {
upBtn.disabled = false; fileInput.value = "";
}
});
// 新建节点与工作流恢复都走同一条路径解析逻辑;绝对路径仅在 input 根目录内映射为 /view。
if (pathW?.value) void refreshPathPreview();
renderSel();
// 初始尺寸:让节点一创建就把编辑器高度收进框内,避免内容溢出。
requestAnimationFrame(() => node._o1vtResize?.());
node._o1vtCleanup = () => {
pathPreviewToken++;
filmToken++;
videoEl.pause?.();
videoEl.removeAttribute("src");
videoEl.load?.();
fileInput.remove?.();
};
};
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = origConfigure?.apply(this, arguments);
requestAnimationFrame(() => this._o1vtRefreshPath?.());
return result;
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._o1vtCleanup?.();
return origRemoved?.apply(this, arguments);
};
// ── 按视频比例自适应节点高度 ──────────────────────────────────
// editorWidget.computeSize 已把编辑器高度纳入,这里用 LiteGraph 的
// computeSize() 重新求节点尺寸,宽度保持当前值,高度按内容收紧。
nodeType.prototype._o1vtResize = function () {
const w = Math.max(this.size?.[0] || 0, 300);
const sz = this.computeSize([w, 0]);
this.setSize([w, sz[1]]);
this.setDirtyCanvas(true, true);
};
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
origResize?.apply(this, arguments);
};
},
});