diff --git a/py/KY_VideoCompare.py b/py/KY_VideoCompare.py index b9a82fb..8b62179 100644 --- a/py/KY_VideoCompare.py +++ b/py/KY_VideoCompare.py @@ -4,9 +4,38 @@ from PIL import Image import folder_paths import os import json +import urllib.parse _CATEGORY = 'KYNode/video' +def get_ky_url(path): + if not path: + return "" + filename = os.path.basename(path) + fullpath_encoded = urllib.parse.quote(path.replace('\\', '/')) + return f"/ky_utils/stream/{filename}?path={fullpath_encoded}" + +def resolve_path(filename, subfolder="", type_="input"): + try: + # handle special subfolder names if they come from the weird ComfyUI logic + if subfolder == 'input' or subfolder == 'output' or subfolder == 'temp': + subfolder = "" + + base_dir = folder_paths.get_directory_by_type(type_) + if not base_dir: + return "" + + # If subfolder is not empty, join it + if subfolder: + full_path = os.path.join(base_dir, subfolder, filename) + else: + full_path = os.path.join(base_dir, filename) + + return os.path.abspath(full_path) + except Exception as e: + print(f"Error resolving path: {e}") + return "" + def process_video_object(video_obj): """ @@ -27,72 +56,86 @@ def process_video_object(video_obj): file_attr = getattr(video_obj, '_VideoFromFile__file', None) # 如果是字符串路径 if isinstance(file_attr, str): + # It might be a relative path or just a filename depending on how it was created + # Usually ComfyUI internally stores relative path or filename for input directory + # Let's try to resolve it. + + # Check if it has input/output/temp in path to guess type filename = os.path.basename(file_attr) subfolder = os.path.dirname(file_attr) - # 尝试确定文件类型 + if 'input' in file_attr: type_ = 'input' elif 'output' in file_attr: type_ = 'output' else: - type_ = 'input' # 默认为input - source = f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" - return source + type_ = 'input' # default + + # If absolute path, use it directly + if os.path.isabs(file_attr) and os.path.exists(file_attr): + return get_ky_url(file_attr) + + full_path = resolve_path(filename, subfolder, type_) + if os.path.exists(full_path): + return get_ky_url(full_path) + + # fallback if resolution failed but it might be a direct path + return get_ky_url(file_attr) + # 如果是BytesIO对象,我们无法直接获取文件名 elif hasattr(file_attr, 'name'): # BytesIO可能有name属性 - filename = os.path.basename(getattr(file_attr, 'name', 'unknown.mp4')) - source = f"/api/view?filename={filename}&type=input" - return source + # Warning: this might be a temporary file that doesn't exist on disk in a standard location + filename = getattr(file_attr, 'name', 'unknown.mp4') + if os.path.isabs(filename): + return get_ky_url(filename) + # Try to find it in input + full_path = resolve_path(os.path.basename(filename), "", "input") + return get_ky_url(full_path) else: # 其他情况,转换为字符串 source = str(file_attr) + if os.path.exists(source): + return get_ky_url(source) return source - # 处理其他可能的属性 - if hasattr(video_obj, 'filename'): - filename = getattr(video_obj, 'filename', '') - subfolder = getattr(video_obj, 'subfolder', '') - type_ = getattr(video_obj, 'type', 'input') - source = f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" - return source + # helper to extract fields + def get_info(obj): + fname = getattr(obj, 'filename', None) or obj.get('filename') if isinstance(obj, dict) else None + sub = getattr(obj, 'subfolder', None) or obj.get('subfolder', "") if isinstance(obj, dict) else "" + typ = getattr(obj, 'type', None) or obj.get('type', "input") if isinstance(obj, dict) else "input" + path = getattr(obj, 'path', None) or obj.get('path', None) if isinstance(obj, dict) else None + return fname, sub, typ, path + + fname, sub, typ, path = get_info(video_obj) + + if path: + return get_ky_url(path) - if isinstance(video_obj, dict): - # 检查常见的键 - if "filename" in video_obj: - filename = video_obj["filename"] - subfolder = video_obj.get("subfolder", "") - type_ = video_obj.get("type", "input") - source = f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" - return source - elif "path" in video_obj: - path = video_obj["path"] - filename = os.path.basename(path) - subfolder = os.path.dirname(path) - source = f"/api/view?filename={filename}&subfolder={subfolder}&type=input" - return source - else: - # 尝试其他可能的键 - source = str(video_obj) - return source - elif isinstance(video_obj, str): + if fname: + full_path = resolve_path(fname, sub, typ) + return get_ky_url(full_path) + + if isinstance(video_obj, str): if os.path.exists(video_obj): - filename = os.path.basename(video_obj) - subfolder = os.path.dirname(video_obj) - source = f"/api/view?filename={filename}&subfolder={subfolder}&type=input" - return source + return get_ky_url(video_obj) else: - source = video_obj - return source - else: - # 其他类型,尝试转换为字符串并检查是否包含有用信息 - source = str(video_obj) - - # 如果字符串中包含filename信息,尝试提取 - if 'filename=' in source and 'type=' in source: - return source - else: - return source + # check if it looks like a relative path in input + full_path = resolve_path(os.path.basename(video_obj), os.path.dirname(video_obj), "input") + if os.path.exists(full_path): + return get_ky_url(full_path) + return video_obj + + # fallback + source = str(video_obj) + # check if it already has filename/type info from some string representation + if 'filename=' in source and 'type=' in source: + # It's an API View URL probably, we can't easily convert it back without parsing + # But if the user wants to use ky_utils, we should try. + # But for safety, maybe just return it if we can't parse? + # Let's leave it for now. + return source + return source class VideoCompareNode: @@ -241,39 +284,11 @@ class KY_ToVideoUrl: if match: video_path = match.group(1) - # 如果是文件路径,转换为API视图URL if os.path.exists(video_path): - filename = os.path.basename(video_path) - # 获取相对于ComfyUI目录的子目录 - try: - # 获取ComfyUI根目录 - comfyui_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - # 计算相对路径 - relative_path = os.path.relpath(video_path, comfyui_root) - subfolder = os.path.dirname(relative_path).replace('\\', '/') - # 从路径中确定type - path_parts = relative_path.split(os.sep) - if 'temp' in path_parts: - type_ = 'temp' - elif 'input' in path_parts: - type_ = 'input' - elif 'output' in path_parts: - type_ = 'output' - else: - type_ = 'input' # 默认值 - except: - # 如果计算相对路径失败,使用原来的逻辑 - subfolder = os.path.dirname(video_path).replace('\\', '/') - type_ = 'input' - if subfolder == 'input' or subfolder == 'output' or subfolder == 'temp': - subfolder = '' - # 构建完整的URL参数 - import urllib.parse - fullpath_encoded = urllib.parse.quote(video_path.replace('\\', '/')) - return f"/api/view?filename={filename}&subfolder={subfolder}&type={type_}&format=video%2Fh264-mp4&fullpath={fullpath_encoded}" + return get_ky_url(video_path) - # 如果已经是API视图URL,直接返回 - if '/api/view' in s and ('filename=' in s or 'type=' in s): + # 如果已经是API视图URL,尝试转换 (Optional, but good for compatibility if input is already a view url) + if '/ky_utils/stream' in s: return s # 尝试从JSON字符串中提取信息 @@ -281,47 +296,24 @@ class KY_ToVideoUrl: try: data = json.loads(s) if isinstance(data, dict): - if "filename" in data: - filename = data["filename"] - subfolder = data.get("subfolder", "") - type_ = data.get("type", "input") - return f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" - elif "path" in data: - path = data["path"] - filename = os.path.basename(path) - subfolder = os.path.dirname(path) - return f"/api/view?filename={filename}&subfolder={subfolder}&type=input" + # try to get absolute path + fname = data.get("filename") + sub = data.get("subfolder", "") + typ = data.get("type", "input") + path = data.get("path") + + if path and os.path.exists(path): + return get_ky_url(path) + if fname: + full_path = resolve_path(fname, sub, typ) + if os.path.exists(full_path): + return get_ky_url(full_path) except: pass - # 如果是文件路径,转换为API视图URL + # 如果是文件路径 if os.path.exists(s): - filename = os.path.basename(s) - try: - # 获取ComfyUI根目录 - comfyui_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - # 计算相对路径 - relative_path = os.path.relpath(s, comfyui_root) - subfolder = os.path.dirname(relative_path).replace('\\', '/') - # 从路径中确定type - path_parts = relative_path.split(os.sep) - if 'temp' in path_parts: - type_ = 'temp' - elif 'input' in path_parts: - type_ = 'input' - elif 'output' in path_parts: - type_ = 'output' - else: - type_ = 'input' # 默认值 - except: - # 如果计算相对路径失败,使用原来的逻辑 - subfolder = os.path.dirname(s).replace('\\', '/') - type_ = 'input' - - # 构建完整的URL参数 - import urllib.parse - fullpath_encoded = urllib.parse.quote(s.replace('\\', '/')) - return f"/api/view?filename={filename}&subfolder={subfolder}&type={type_}&format=video%2Fh264-mp4&fullpath={fullpath_encoded}" + return get_ky_url(s) # 否则返回原字符串 return s @@ -394,40 +386,42 @@ def save_image_to_output(img, name_prefix="img"): Image.fromarray(_to_uint8_rgb(arr)).save(fullpath) else: return "" - return f"/api/view?filename={filename}&type=output&subfolder={subfolder}" + return get_ky_url(fullpath) def process_image_object(image_obj): if image_obj is None: return "" - # if has typical attributes - if hasattr(image_obj, 'filename'): - filename = getattr(image_obj, 'filename', '') - subfolder = getattr(image_obj, 'subfolder', '') - type_ = getattr(image_obj, 'type', 'input') - return f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" + + # helper to check dict + fname = getattr(image_obj, 'filename', None) or image_obj.get('filename') if isinstance(image_obj, dict) else None + sub = getattr(image_obj, 'subfolder', None) or image_obj.get('subfolder', "") if isinstance(image_obj, dict) else "" + typ = getattr(image_obj, 'type', None) or image_obj.get('type', "input") if isinstance(image_obj, dict) else "input" + path = getattr(image_obj, 'path', None) or image_obj.get('path', None) if isinstance(image_obj, dict) else None + + if path: + return get_ky_url(path) + + if fname: + full_path = resolve_path(fname, sub, typ) + return get_ky_url(full_path) + if isinstance(image_obj, dict): - if "filename" in image_obj: - filename = image_obj["filename"] - subfolder = image_obj.get("subfolder", "") - type_ = image_obj.get("type", "input") - return f"/api/view?type={type_}&filename={filename}&subfolder={subfolder}" - if "path" in image_obj: - path = image_obj["path"] - filename = os.path.basename(path) - subfolder = os.path.dirname(path) - return f"/api/view?filename={filename}&subfolder={subfolder}&type=input" if "image" in image_obj: return save_image_to_output(image_obj["image"]) if "images" in image_obj: imgs = image_obj["images"] if isinstance(imgs, (list, tuple)) and imgs: return save_image_to_output(imgs[0]) + if isinstance(image_obj, str): if os.path.exists(image_obj): - filename = os.path.basename(image_obj) - subfolder = os.path.dirname(image_obj) - return f"/api/view?filename={filename}&subfolder={subfolder}&type=input" + return get_ky_url(image_obj) + # Try resolving as input file + full_path = resolve_path(os.path.basename(image_obj), os.path.dirname(image_obj), "input") + if os.path.exists(full_path): + return get_ky_url(full_path) return image_obj + # save arbitrary object to output and return view url return save_image_to_output(image_obj) diff --git a/web/python-editor.js b/web/python-editor.js index 0b9fd3a..e40bfe0 100644 --- a/web/python-editor.js +++ b/web/python-editor.js @@ -1,5 +1,4 @@ import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; // Constants const MAX_CHAR_VARNAME = 50; @@ -319,7 +318,8 @@ app.registerExtension({ name: "KYNode.KY_Eval_Python", async setup() { // 🔥 监听后端事件 - app.socket.on("python_editor_error", (data) => { + if (app.socket) { + app.socket.on("python_editor_error", (data) => { console.log("收到错误事件:", data); // 获取当前所有节点,找到我们自定义的那个 @@ -333,6 +333,9 @@ app.registerExtension({ } } }); + } else { + console.warn("[KY_Eval_Python] app.socket undefined during setup"); + } }, getCustomWidgets(app) { return { diff --git a/web/python-editor.js.bak b/web/python-editor.js.bak deleted file mode 100644 index 743d13d..0000000 --- a/web/python-editor.js.bak +++ /dev/null @@ -1,500 +0,0 @@ -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; - -// Constants -const MAX_CHAR_VARNAME = 50; -const LIST_THEMES = ["monokai"]; -const varTypes = [ - "int", - "boolean", - "string", - "float", - "json", - "list", - "dict", -]; -const typeMap = { - int: 'int', - boolean: 'bool', - string: 'str', - float: 'float', - json: 'json', - list: 'list', - dict: 'dict', -} -const DEFAULT_TEMPLATES = { - py: `import re, json, os, traceback -from time import strftime -def runCode(): - nowDataTime = strftime("%Y-%m-%d %H:%M:%S") - return f"Hello ComfyUI with us today {nowDataTime}!" -r0 = runCode() - -`, -}; - -function getPostition(node, ctx, w_width, y, n_height) { - const margin = 5; - - const rect = ctx.canvas.getBoundingClientRect(); - const transform = new DOMMatrix() - .scaleSelf(rect.width / ctx.canvas.width, rect.height / ctx.canvas.height) - .multiplySelf(ctx.getTransform()) - .translateSelf(margin, margin + y); - const scale = new DOMMatrix().scaleSelf(transform.a, transform.d); - - return { - transformOrigin: "0 0", - transform: scale, - left: `${transform.a + transform.e + rect.left}px`, - top: `${transform.d + transform.f + rect.top}px`, - maxWidth: `${w_width - margin * 2}px`, - maxHeight: `${n_height - margin * 2 - y - 15}px`, - width: `${w_width - margin * 2}px`, - height: "90%", - position: "absolute", - scrollbarColor: "var(--descrip-text) var(--bg-color)", - scrollbarWidth: "thin", - zIndex: app.graph._nodes.indexOf(node), - }; -} - -function findWidget(node, value, attr = "name", func = "find") { - return node?.widgets - ? node.widgets[func]((w) => - Array.isArray(value) ? value.includes(w[attr]) : w[attr] === value - ) - : null; -} - -// Create editor code -function codeEditor(node, inputName, inputData) { - const widget = { - type: "pycode", - name: inputName, - options: { hideOnZoom: true }, - value: - inputData[1]?.default || - `def my(a, b=1): - return a * b
- -r0 = str(my(23, 9))`, - draw(ctx, node, widget_width, y, widget_height) { - const hidden = - node.flags?.collapsed || - (!!widget.options.hideOnZoom && app.canvas.ds.scale < 0.5) || - widget.type === "converted-widget" || - widget.type === "hidden"; - - widget.codeElement.hidden = hidden; - - if (hidden) { - widget.options.onHide?.(widget); - return; - } - - Object.assign( - this.codeElement.style, - getPostition(node, ctx, widget_width, y, node.size[1]) - ); - }, - computeSize(...args) { - return [500, 250]; - }, - }; - - // 创建一个标准的 textarea 元素 - widget.codeElement = document.createElement('div'); - widget.codeElement.className = 'python-editor-container'; - widget.codeElement.style.width = '100%'; - widget.codeElement.style.height = '100%'; - widget.codeElement.style.position = 'relative'; - - // 创建 textarea 作为编辑器 - const textarea = document.createElement('textarea'); - textarea.value = widget.value; - textarea.style.width = '100%'; - textarea.style.height = '80%'; - textarea.style.fontFamily = 'cursive'; - textarea.style.fontSize = '1.1em'; - textarea.style.boxSizing = 'border-box'; - textarea.style.backgroundColor = '#000'; - textarea.style.color = '#2bb356ff'; - textarea.style.resize = 'none'; - textarea.style.wordBreak = 'break-word'; - textarea.wrap = 'soft'; - textarea.spellcheck = false; - - // 创建错误显示区域 - const errorDiv = document.createElement('div'); - errorDiv.className = 'error-message'; - errorDiv.style.background = 'rgba(255, 0, 0, 0.1)'; - errorDiv.style.color = '#ff4444'; - errorDiv.style.padding = '4px'; - errorDiv.style.marginTop = '4px'; - errorDiv.style.borderRadius = '4px'; - errorDiv.style.fontFamily = 'monospace'; - errorDiv.style.fontSize = '12px'; - errorDiv.style.display = 'none'; - - // 创建输出区域 - const outputDiv = document.createElement('div'); - outputDiv.style.background = '#2a2a2a'; - outputDiv.style.color = '#ffffff'; - outputDiv.style.padding = '8px'; - outputDiv.style.marginTop = '4px'; - outputDiv.style.borderRadius = '4px'; - outputDiv.style.fontFamily = 'monospace'; - outputDiv.style.fontSize = '12px'; - outputDiv.style.height = '20%'; - outputDiv.style.overflowY = 'auto'; - - // 将元素添加到容器 - widget.codeElement.appendChild(textarea); - widget.codeElement.appendChild(errorDiv); - widget.codeElement.appendChild(outputDiv); - - // 设置编辑器引用 - widget.editor = { - getValue: () => textarea.value, - setValue: (value) => { - textarea.value = value; - }, - clearOutput: () => { - outputDiv.innerHTML = ''; - }, - showError: (message) => { - errorDiv.textContent = message; - errorDiv.style.display = 'block'; - setTimeout(() => { - errorDiv.style.display = 'none'; - }, 5000); - }, - addOutput: (text) => { - const outputLine = document.createElement('div'); - outputLine.textContent = text; - outputDiv.appendChild(outputLine); - outputDiv.scrollTop = outputDiv.scrollHeight; - } - }; - - // 添加事件监听 - textarea.addEventListener('input', () => { - widget.value = textarea.value; - if (node) { - node.setDirtyCanvas(true); - } - }); - - // 支持Tab键缩进 - textarea.addEventListener('keydown', (e) => { - if (e.key === 'Tab') { - e.preventDefault(); - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - - // 在光标位置插入4个空格 - textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end); - - // 将光标位置移动到插入空格后的位置 - textarea.selectionStart = textarea.selectionEnd = start + 4; - - // 触发 input 事件以更新 widget.value - textarea.dispatchEvent(new Event('input')); - } - }); - - widget.codeElement.hidden = true; - document.body.appendChild(widget.codeElement); - - const collapse = node.collapse; - node.collapse = function () { - collapse.apply(this, arguments); - if (this.flags?.collapsed) { - widget.codeElement.hidden = true; - } else { - if (this.flags?.collapsed === false) { - widget.codeElement.hidden = false; - } - } - }; - - return widget; -} - - -// Save data to workflow forced! -function saveValue() { - app?.extensionManager?.workflow?.activeWorkflow?.changeTracker?.checkState(); -} - -// Register extensions -app.registerExtension({ - name: "KYNode.KY_Eval_Python", - getCustomWidgets(app) { - return { - PYCODE: (node, inputName, inputData, app) => { - const widget = codeEditor(node, inputName, inputData); - - - - const varTypeList = node.addWidget( - "combo", - "select_type", - "string", - (v) => { - // widget.editor.setTheme(`ace/theme/${varTypeList.value}`); - }, - { - values: varTypes, - serialize: false, - } - ); - - // 6. 使用 addDOMWidget 将容器添加到节点上 - // - 第一个参数是 widget 的名称,在节点内部需要是唯一的。 - // - 第二个参数是 widget 的类型,对于自定义 DOM 元素,通常是 "div"。 - // - 第三个参数是您创建的 DOM 元素。 - // - 第四个参数是一个选项对象,可以用来配置 widget。 - // node.addDOMWidget("rowOfButtons", "div", container, { - // }); - node.addWidget( - "button", - "Add Input variable", - "add_input_variable", - async () => { - // Input name variable and check - let nameInput = node?.inputs?.length - ? `p${node.inputs.length - 1}` - : "p0"; - - const currentWidth = node.size[0]; - let tp = varTypeList.value - nameInput = nameInput + '_' + typeMap[tp] - node.addInput(nameInput, "*"); - node.setSize([currentWidth, node.size[1]]); - let cv = widget.editor.getValue(); - if(tp == 'json') { - cv = cv + '\n' + nameInput + ' = json.loads('+ nameInput + ')' - } else if(tp == 'list') { - cv = cv + '\nfor i in '+ nameInput +':\n print(i)' - } else if(tp == 'dict') { - cv = cv + '\nval = ' + nameInput + '["key"]' - } else { - cv = cv + '\n' + nameInput + ' = ' + typeMap[tp] + '('+ nameInput + ')' - } - widget.editor.setValue(cv) - saveValue(); - } - ); - - node.addWidget( - "button", - "Add Output variable", - "add_output_variable", - async () => { - const currentWidth = node.size[0]; - // Output name variable - let nameOutput = node?.outputs?.length - ? `r${node.outputs.length}` - : "r0"; - let tp = varTypeList.value - nameOutput = nameOutput + '_' + typeMap[tp] - node.addOutput(nameOutput, tp); - node.setSize([currentWidth, node.size[1]]); - let cv = widget.editor.getValue(); - if(tp == 'json') { - cv = cv + '\n' + nameOutput + ' = json.dumps('+ nameOutput + ')' - } else if(tp == 'list') { - cv = cv + '\n' + nameOutput + ' = []' - } else if(tp == 'dict') { - cv = cv + '\n' + nameOutput + ' = {}' - } else { - cv = cv + '\n' + nameOutput + ' = ' + typeMap[tp] + '('+ nameOutput + ')' - } - widget.editor.setValue(cv) - saveValue(); - } - ); - - node.onRemoved = function () { - for (const w of node?.widgets) { - if (w?.codeElement) w.codeElement.remove(); - } - }; - - node.addCustomWidget(widget); - - return widget; - }, - }; - }, - - async beforeRegisterNodeDef(nodeType, nodeData, app) { - // --- IDENode - if (nodeData.name === "KY_Eval_Python") { - // Node Created - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = async function () { - const ret = onNodeCreated - ? onNodeCreated.apply(this, arguments) - : undefined; - - const node_title = await this.getTitle(); - const nodeName = `${nodeData.name}_${this.id}`; - - console.log(`Create ${nodeData.name}: ${nodeName}`); - this.name = nodeName; - - // Create default inputs, when first create node - if (this?.inputs?.length<2) { - ["p0_str"].forEach((inputName) => { - const currentWidth = this.size[0]; - this.addInput(inputName, "*"); - this.setSize([currentWidth, this.size[1]]); - }); - } - - // const widgetEditor = findWidget(this, "pycode", "type"); - - this.setSize([530, this.size[1]]); - - return ret; - }; - - const onDrawForeground = nodeType.prototype.onDrawForeground; - nodeType.prototype.onDrawForeground = function (ctx) { - const r = onDrawForeground?.apply?.(this, arguments); - - // if (this.flags?.collapsed) return r; - - if (this?.outputs?.length) { - for (let o = 0; o < this.outputs.length; o++) { - const { name, type } = this.outputs[o]; - const colorType = LGraphCanvas.link_type_colors[type.toUpperCase()]; - const nameSize = ctx.measureText(name); - const typeSize = ctx.measureText( - `[${type === "*" ? "any" : type.toLowerCase()}]` - ); - - ctx.fillStyle = colorType === "" ? "#AAA" : colorType; - ctx.font = "12px Arial, sans-serif"; - ctx.textAlign = "right"; - ctx.fillText( - `[${type === "*" ? "any" : type.toLowerCase()}]`, - this.size[0] - nameSize.width - typeSize.width, - o * 20 + 19 - ); - } - } - - if (this?.inputs?.length) { - const not_showing = ["select_type", "pycode"]; - for (let i = 1; i < this.inputs.length; i++) { - const { name, type } = this.inputs[i]; - if (not_showing.includes(name)) continue; - const colorType = LGraphCanvas.link_type_colors[type.toUpperCase()]; - const nameSize = ctx.measureText(name); - - ctx.fillStyle = !colorType || colorType === "" ? "#AAA" : colorType; - ctx.font = "12px Arial, sans-serif"; - ctx.textAlign = "left"; - ctx.fillText( - `[${type === "*" ? "any" : type.toLowerCase()}]`, - nameSize.width + 25, - i * 20 - ); - } - } - return r; - }; - - // Node Configure - const onConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function (node) { - onConfigure?.apply(this, arguments); - if (node?.widgets_values?.length) { - const widget_code_id = findWidget( - this, - "pycode", - "type", - "findIndex" - ); - const widget_theme_id = findWidget( - this, - "varTypeList", - "name", - "findIndex" - ); - const widget_language_id = findWidget( - this, - "language", - "name", - "findIndex" - ); - - const editor = this.widgets[widget_code_id]?.editor; - - if (editor) { - // 适配 textarea 编辑器 - if (editor.setValue) { - editor.setValue(this.widgets_values[widget_code_id]); - } - } - } - }; - - // ExtraMenuOptions - const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; - nodeType.prototype.getExtraMenuOptions = function (_, options) { - getExtraMenuOptions?.apply(this, arguments); - - const past_index = options.length - 1; - const past = options[past_index]; - - if (!!past) { - // Inputs remove - for (const input_idx in this.inputs) { - const input = this.inputs[input_idx]; - - if (["language", "select_type"].includes(input.name)) continue; - - options.splice(past_index + 1, 0, { - content: `Remove Input ${input.name}`, - callback: (e) => { - const currentWidth = this.size[0]; - if (input.link) { - app.graph.removeLink(input.link); - } - this.removeInput(input_idx); - this.setSize([80, this.size[1]]); - saveValue(); - }, - }); - } - - // Output remove - for (const output_idx in this.outputs) { - const output = this.outputs[output_idx]; - - if (output.name === "r0") continue; - - options.splice(past_index + 1, 0, { - content: `Remove Output ${output.name}`, - callback: (e) => { - const currentWidth = this.size[0]; - if (output.link) { - app.graph.removeLink(output.link); - } - this.removeOutput(output_idx); - this.setSize([currentWidth, this.size[1]]); - saveValue(); - }, - }); - } - } - }; - // end - ExtraMenuOptions - } - }, -}); \ No newline at end of file diff --git a/web/video_compare.html b/web/video_compare.html index 6cffd90..be77b84 100644 --- a/web/video_compare.html +++ b/web/video_compare.html @@ -1,145 +1,241 @@ - - + + + Video Compare - - - + + - -
- - - -
- -
- -
- -
-
- \ No newline at end of file + } + + videoContainer.addEventListener("mousemove", (e) => { + if (isDragging) { + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + panX += dx; + panY += dy; + updateTransform(); + } else { + trackLocation(e); + } + }); + videoContainer.addEventListener("touchstart", trackLocation, false); + videoContainer.addEventListener("touchmove", trackLocation, false); + + videoContainer.addEventListener("mousedown", (e) => { + if (e.button === 2) { + // Right click + isDragging = true; + lastX = e.clientX; + lastY = e.clientY; + e.preventDefault(); + } + }); + videoContainer.addEventListener("mouseup", () => { + isDragging = false; + }); + videoContainer.addEventListener("contextmenu", (e) => e.preventDefault()); + videoContainer.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const zoomSpeed = 0.1; + const oldScale = scale; + if (e.deltaY < 0) scale *= 1 + zoomSpeed; + else scale /= 1 + zoomSpeed; + scale = Math.max(0.1, Math.min(scale, 10)); + + const rect = videoContainer.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const rx = (mx - panX) / oldScale; + const ry = (my - panY) / oldScale; + panX = mx - rx * scale; + panY = my - ry * scale; + updateTransform(); + }, + { passive: false }, + ); + + document + .getElementById("reload-btn") + .addEventListener("click", reloadVideos); + // 视频加载完成后同步播放 + videoA.addEventListener("loadeddata", function () { + if (videoB.readyState >= 3) { + videoA.play(); + videoB.play(); + } + }); + videoB.addEventListener("loadeddata", function () { + if (videoA.readyState >= 3) { + videoA.play(); + videoB.play(); + } + }); + // 处理视频播放同步 + function syncVideos() { + if (videoA.paused && videoB.paused) return; + + const diff = Math.abs(videoA.currentTime - videoB.currentTime); + if (diff > 0.1) { + // 同步视频时间 + videoB.currentTime = videoA.currentTime; + } + } + // 定期同步视频 + setInterval(syncVideos, 100); + + + diff --git a/web/video_file_compare.js b/web/video_file_compare.js index 6f77139..a01194c 100644 --- a/web/video_file_compare.js +++ b/web/video_file_compare.js @@ -1,6 +1,5 @@ // 用于实现视频对比的JavaScript代码 import { app } from "/scripts/app.js"; -import { api } from "/scripts/api.js"; // 注册节点扩展 app.registerExtension({ @@ -113,9 +112,10 @@ function ensureVCStyle() { .ky-vc-btn{padding:5px 12px;background:var(--comfy-input-bg);border:1px solid var(--border-color);color:var(--fg-color);border-radius:4px;cursor:pointer} .ky-vc-btn.primary{background:var(--p-700);color:#fff} .ky-vc-body{flex:1;padding:0} - #ky-vc-container{position:relative;width:100%;height:100%;line-height:0;background:#000} - #ky-vc-container>video{position:absolute;top:0;left:0;background:#000} - #ky-vc-container>img{position:absolute;top:0;left:0;background:#000} + #ky-vc-container{position:relative;width:100%;height:100%;line-height:0;background:#000;overflow:hidden} + .ky-vc-zoom-layer{position:absolute;top:0;left:0;width:100%;height:100%;transform-origin:0 0;will-change:transform} + #ky-vc-container>div>video{position:absolute;top:0;left:0;background:#000} + #ky-vc-container>div>img{position:absolute;top:0;left:0;background:#000} #ky-vc-clipper{width:50%;position:absolute;top:0;bottom:0;overflow:hidden;border-right:1px dashed #ccc} #ky-vc-clipper video{position:absolute;top:0;left:0;background:#000} #ky-vc-clipper img{position:absolute;top:0;left:0;background:#000} @@ -143,13 +143,15 @@ function openCompareDialog(a, b) {
- -
-
@@ -164,6 +166,8 @@ function openCompareDialog(a, b) { const clipper = content.querySelector('#ky-vc-clipper'); const vA = vContainer.getElementsByTagName('video')[0]; const vB = clipper.getElementsByTagName('video')[0]; + const zoomLayer = vContainer.querySelector('.ky-vc-zoom-layer'); + function toStr(x){return (Array.isArray(x)?x.join(''):x||"").toString();} inpA.value = toStr(a); inpB.value = toStr(b); @@ -171,7 +175,18 @@ function openCompareDialog(a, b) { bSrc.src = toStr(b); vA.load(); vB.load(); - function track(e){const rect=vContainer.getBoundingClientRect();const pos=(e.pageX-rect.left)/vContainer.offsetWidth*100;if(pos<=100){clipper.style.width=pos+"%";vB.style.zIndex=3;}} + + let scale = 1, panX = 0, panY = 0, isDragging = false, lastX = 0, lastY = 0; + function updateTransform() { zoomLayer.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; } + + function track(e){ + if (isDragging) return; + const rect=zoomLayer.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const pos=(clientX-rect.left)/rect.width*100; + if(pos>=0 && pos<=100){clipper.style.width=pos+"%";vB.style.zIndex=3;} + } + function layout(){ const cw = vContainer.clientWidth; const ch = vContainer.clientHeight; const aw = vA.videoWidth||1920; const ah = vA.videoHeight||1080; @@ -185,7 +200,49 @@ function openCompareDialog(a, b) { vA.addEventListener('loadedmetadata', onMeta); vB.addEventListener('loadedmetadata', onMeta); window.addEventListener('resize', layout); - vContainer.addEventListener('mousemove',track,false); + + vContainer.addEventListener('mousemove', (e) => { + if (isDragging) { + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + panX += dx; + panY += dy; + updateTransform(); + } else { + track(e); + } + }); + vContainer.addEventListener('mousedown', (e) => { + if (e.button === 2) { + isDragging = true; + lastX = e.clientX; + lastY = e.clientY; + e.preventDefault(); + } + }); + vContainer.addEventListener('mouseup', () => { isDragging = false; }); + vContainer.addEventListener('contextmenu', e => e.preventDefault()); + vContainer.addEventListener('wheel', (e) => { + e.preventDefault(); + const zoomSpeed = 0.1; + const oldScale = scale; + if (e.deltaY < 0) scale *= (1 + zoomSpeed); + else scale /= (1 + zoomSpeed); + scale = Math.max(0.1, Math.min(scale, 10)); + + const rect = vContainer.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const rx = (mx - panX) / oldScale; + const ry = (my - panY) / oldScale; + panX = mx - rx * scale; + panY = my - ry * scale; + updateTransform(); + }, { passive: false }); + + // Touch support for slider (basic) vContainer.addEventListener('touchstart',track,false); vContainer.addEventListener('touchmove',track,false); function sync(){if(vA.paused&&vB.paused)return;const d=Math.abs(vA.currentTime-vB.currentTime);if(d>0.1){vB.currentTime=vA.currentTime;}} @@ -273,9 +330,11 @@ function openImageCompareDialog(a, b){
- -
- +
+ +
+ +
@@ -293,7 +352,19 @@ function openImageCompareDialog(a, b){ inpB.value = toStr(b); imgA.src = toStr(a); imgB.src = toStr(b); - function track(e){const rect=vContainer.getBoundingClientRect();const pos=(e.pageX-rect.left)/vContainer.offsetWidth*100;if(pos<=100){clipper.style.width=pos+"%";imgB.style.zIndex=3;}} + + let scale = 1, panX = 0, panY = 0, isDragging = false, lastX = 0, lastY = 0; + const zoomLayer = vContainer.querySelector('.ky-vc-zoom-layer'); + function updateTransform() { zoomLayer.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; } + + function track(e){ + if(isDragging) return; + const rect=zoomLayer.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const pos=(clientX-rect.left)/rect.width*100; + if(pos>=0 && pos<=100){clipper.style.width=pos+"%";imgB.style.zIndex=3;} + } + function layout(){ const cw = vContainer.clientWidth; const ch = vContainer.clientHeight; const aw = imgA.naturalWidth||1024; const ah = imgA.naturalHeight||768; @@ -306,7 +377,49 @@ function openImageCompareDialog(a, b){ imgA.addEventListener('load', layout); imgB.addEventListener('load', layout); window.addEventListener('resize', layout); - vContainer.addEventListener('mousemove',track,false); + + vContainer.addEventListener('mousemove', (e) => { + if (isDragging) { + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + panX += dx; + panY += dy; + updateTransform(); + } else { + track(e); + } + }); + vContainer.addEventListener('mousedown', (e) => { + if (e.button === 2) { + isDragging = true; + lastX = e.clientX; + lastY = e.clientY; + e.preventDefault(); + } + }); + vContainer.addEventListener('mouseup', () => { isDragging = false; }); + vContainer.addEventListener('contextmenu', e => e.preventDefault()); + vContainer.addEventListener('wheel', (e) => { + e.preventDefault(); + const zoomSpeed = 0.1; + const oldScale = scale; + if (e.deltaY < 0) scale *= (1 + zoomSpeed); + else scale /= (1 + zoomSpeed); + scale = Math.max(0.1, Math.min(scale, 10)); + + const rect = vContainer.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const rx = (mx - panX) / oldScale; + const ry = (my - panY) / oldScale; + panX = mx - rx * scale; + panY = my - ry * scale; + updateTransform(); + }, { passive: false }); + + // Touch support (basic) vContainer.addEventListener('touchstart',track,false); vContainer.addEventListener('touchmove',track,false); content.querySelector('#ky-vc-reload').addEventListener('click',()=>{imgA.src=inpA.value;imgB.src=inpB.value;});