Update image and video workflow nodes
This commit is contained in:
+46
-40
@@ -20,11 +20,12 @@ _seeder_filter = lambda record: not any(
|
|||||||
)
|
)
|
||||||
logging.getLogger().addFilter(_seeder_filter)
|
logging.getLogger().addFilter(_seeder_filter)
|
||||||
|
|
||||||
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, O1keyGrokImage, KVideoFirstLast, KVideoImage2Video
|
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, O1keyGPTImageBatch, O1keyGrokImage, KVideoFirstLast, KVideoImage2Video
|
||||||
from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch, SaveImageFormat
|
from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch, SaveImageFormat
|
||||||
from .nodes import O1keySavePSD
|
from .nodes import O1keySavePSD
|
||||||
from .nodes import O1keyRemoveBackground
|
from .nodes import O1keyRemoveBackground
|
||||||
from .nodes import O1keyColorRemoveBG
|
from .nodes import O1keyColorRemoveBG
|
||||||
|
from .nodes import O1keyGridSplitter
|
||||||
|
|
||||||
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
||||||
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
||||||
@@ -85,6 +86,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"StreamPreview": StreamPreview,
|
"StreamPreview": StreamPreview,
|
||||||
"DoubaoImage": DoubaoImage,
|
"DoubaoImage": DoubaoImage,
|
||||||
"O1keyGPTImage": O1keyGPTImage,
|
"O1keyGPTImage": O1keyGPTImage,
|
||||||
|
"O1keyGPTImageBatch": O1keyGPTImageBatch,
|
||||||
"O1keyGrokImage": O1keyGrokImage,
|
"O1keyGrokImage": O1keyGrokImage,
|
||||||
"KVideoFirstLast": KVideoFirstLast,
|
"KVideoFirstLast": KVideoFirstLast,
|
||||||
"KVideoImage2Video": KVideoImage2Video,
|
"KVideoImage2Video": KVideoImage2Video,
|
||||||
@@ -98,6 +100,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"O1keySavePSD": O1keySavePSD,
|
"O1keySavePSD": O1keySavePSD,
|
||||||
"O1keyRemoveBackground": O1keyRemoveBackground,
|
"O1keyRemoveBackground": O1keyRemoveBackground,
|
||||||
"O1keyColorRemoveBG": O1keyColorRemoveBG,
|
"O1keyColorRemoveBG": O1keyColorRemoveBG,
|
||||||
|
"O1keyGridSplitter": O1keyGridSplitter,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -123,6 +126,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"StreamPreview": "流式文本预览",
|
"StreamPreview": "流式文本预览",
|
||||||
"DoubaoImage": "豆包生图",
|
"DoubaoImage": "豆包生图",
|
||||||
"O1keyGPTImage": "o1key GPT Image",
|
"O1keyGPTImage": "o1key GPT Image",
|
||||||
|
"O1keyGPTImageBatch": "o1key GPT Image(批量)",
|
||||||
"O1keyGrokImage": "Grok Image",
|
"O1keyGrokImage": "Grok Image",
|
||||||
"KVideoFirstLast": "K26 图生视频(首尾帧)",
|
"KVideoFirstLast": "K26 图生视频(首尾帧)",
|
||||||
"KVideoImage2Video": "K26 图生视频",
|
"KVideoImage2Video": "K26 图生视频",
|
||||||
@@ -136,6 +140,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"O1keySavePSD": "保存 PSD(分层)",
|
"O1keySavePSD": "保存 PSD(分层)",
|
||||||
"O1keyRemoveBackground": "去背景(rembg)",
|
"O1keyRemoveBackground": "去背景(rembg)",
|
||||||
"O1keyColorRemoveBG": "颜色去背景",
|
"O1keyColorRemoveBG": "颜色去背景",
|
||||||
|
"O1keyGridSplitter": "合并图智能切割",
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web"
|
WEB_DIRECTORY = "./web"
|
||||||
@@ -149,6 +154,35 @@ try:
|
|||||||
import folder_paths
|
import folder_paths
|
||||||
from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES
|
from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES
|
||||||
|
|
||||||
|
def _get_o1key_server_port():
|
||||||
|
try:
|
||||||
|
import comfy.cli_args as _cli_args
|
||||||
|
args = getattr(_cli_args, "args", None)
|
||||||
|
port = getattr(args, "port", None) if args else None
|
||||||
|
port = port or getattr(_cli_args, "server_port", None) or getattr(_cli_args, "port", None)
|
||||||
|
if port is not None:
|
||||||
|
return str(int(port))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import sys as _sys
|
||||||
|
for idx, arg in enumerate(_sys.argv):
|
||||||
|
if arg in ("--port", "--listen-port") and idx + 1 < len(_sys.argv):
|
||||||
|
return str(int(_sys.argv[idx + 1]))
|
||||||
|
for prefix in ("--port=", "--listen-port="):
|
||||||
|
if arg.startswith(prefix):
|
||||||
|
return str(int(arg.split("=", 1)[1]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "8188"
|
||||||
|
|
||||||
|
def _get_o1key_history_meta_file(output_dir):
|
||||||
|
import os as _os_history
|
||||||
|
return _os_history.path.join(
|
||||||
|
output_dir,
|
||||||
|
f".o1key_history_{_get_o1key_server_port()}.json",
|
||||||
|
)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/o1key/input_dir")
|
@PromptServer.instance.routes.get("/o1key/input_dir")
|
||||||
async def get_input_dir(request):
|
async def get_input_dir(request):
|
||||||
import os
|
import os
|
||||||
@@ -220,11 +254,11 @@ try:
|
|||||||
@PromptServer.instance.routes.get("/o1key/output_history")
|
@PromptServer.instance.routes.get("/o1key/output_history")
|
||||||
async def get_output_history(request):
|
async def get_output_history(request):
|
||||||
"""读取 output 目录文件,按执行分组返回 /api/jobs 兼容格式"""
|
"""读取 output 目录文件,按执行分组返回 /api/jobs 兼容格式"""
|
||||||
import os, uuid, json as _json
|
import os, json as _json
|
||||||
limit = int(request.query.get("limit", "200"))
|
limit = int(request.query.get("limit", "200"))
|
||||||
offset = int(request.query.get("offset", "0"))
|
offset = int(request.query.get("offset", "0"))
|
||||||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||||||
meta_file = os.path.join(output_dir, ".o1key_history.json")
|
meta_file = _get_o1key_history_meta_file(output_dir)
|
||||||
meta = {}
|
meta = {}
|
||||||
if os.path.isfile(meta_file):
|
if os.path.isfile(meta_file):
|
||||||
try:
|
try:
|
||||||
@@ -235,7 +269,7 @@ try:
|
|||||||
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
||||||
# 收集所有文件并按 workflow_id 分组
|
# 收集所有文件并按 workflow_id 分组
|
||||||
all_files = []
|
all_files = []
|
||||||
for fname in os.listdir(output_dir):
|
for fname in meta.keys():
|
||||||
ext = os.path.splitext(fname)[1].lower()
|
ext = os.path.splitext(fname)[1].lower()
|
||||||
if ext not in supported_ext:
|
if ext not in supported_ext:
|
||||||
continue
|
continue
|
||||||
@@ -247,14 +281,11 @@ try:
|
|||||||
all_files.append({"name": fname, "mtime": mtime, "media": media})
|
all_files.append({"name": fname, "mtime": mtime, "media": media})
|
||||||
# 按 workflow_id 分组(同一次执行合并为一个 job)
|
# 按 workflow_id 分组(同一次执行合并为一个 job)
|
||||||
groups = {}
|
groups = {}
|
||||||
ungrouped = []
|
|
||||||
for f in all_files:
|
for f in all_files:
|
||||||
m = meta.get(f["name"], {})
|
m = meta.get(f["name"], {})
|
||||||
wid = m.get("workflow_id")
|
wid = m.get("workflow_id")
|
||||||
if wid:
|
if wid:
|
||||||
groups.setdefault(wid, []).append((f, m))
|
groups.setdefault(wid, []).append((f, m))
|
||||||
else:
|
|
||||||
ungrouped.append((f, {}))
|
|
||||||
# 构建 job 列表
|
# 构建 job 列表
|
||||||
jobs = []
|
jobs = []
|
||||||
for wid, items in groups.items():
|
for wid, items in groups.items():
|
||||||
@@ -280,27 +311,6 @@ try:
|
|||||||
"execution_error": None,
|
"execution_error": None,
|
||||||
"workflow_id": wid,
|
"workflow_id": wid,
|
||||||
})
|
})
|
||||||
# 无元数据的文件各自作为独立 job
|
|
||||||
for f, m in ungrouped:
|
|
||||||
mtime_ms = int(f["mtime"] * 1000)
|
|
||||||
job_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f["name"]))
|
|
||||||
jobs.append({
|
|
||||||
"id": job_id,
|
|
||||||
"status": "completed",
|
|
||||||
"create_time": mtime_ms,
|
|
||||||
"execution_start_time": mtime_ms,
|
|
||||||
"execution_end_time": mtime_ms,
|
|
||||||
"preview_output": {
|
|
||||||
"filename": f["name"],
|
|
||||||
"subfolder": "",
|
|
||||||
"type": "output",
|
|
||||||
"nodeId": "0",
|
|
||||||
"mediaType": f["media"],
|
|
||||||
},
|
|
||||||
"outputs_count": 1,
|
|
||||||
"execution_error": None,
|
|
||||||
"workflow_id": None,
|
|
||||||
})
|
|
||||||
# 按时间倒序排列,分页
|
# 按时间倒序排列,分页
|
||||||
jobs.sort(key=lambda x: x["create_time"], reverse=True)
|
jobs.sort(key=lambda x: x["create_time"], reverse=True)
|
||||||
total = len(jobs)
|
total = len(jobs)
|
||||||
@@ -349,11 +359,11 @@ try:
|
|||||||
|
|
||||||
@PromptServer.instance.routes.get("/o1key/job_detail/{job_id}")
|
@PromptServer.instance.routes.get("/o1key/job_detail/{job_id}")
|
||||||
async def get_job_detail(request):
|
async def get_job_detail(request):
|
||||||
"""根据 job_id (workflow_id 或 uuid5) 返回含工作流的 job 详情"""
|
"""根据 job_id 返回当前端口持久化历史中的 job 详情"""
|
||||||
import os, uuid, struct, json as _json
|
import os, struct, json as _json
|
||||||
job_id = request.match_info["job_id"]
|
job_id = request.match_info["job_id"]
|
||||||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||||||
meta_file = os.path.join(output_dir, ".o1key_history.json")
|
meta_file = _get_o1key_history_meta_file(output_dir)
|
||||||
meta = {}
|
meta = {}
|
||||||
if os.path.isfile(meta_file):
|
if os.path.isfile(meta_file):
|
||||||
try:
|
try:
|
||||||
@@ -362,17 +372,15 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
supported_ext = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'}
|
||||||
# 查找属于该 job 的所有文件(按 workflow_id 或 uuid5 匹配)
|
# 只在当前端口的持久化记录中查找该 job 的文件
|
||||||
matched_files = []
|
matched_files = []
|
||||||
for fname in os.listdir(output_dir):
|
for fname in meta.keys():
|
||||||
ext = os.path.splitext(fname)[1].lower()
|
ext = os.path.splitext(fname)[1].lower()
|
||||||
if ext not in supported_ext:
|
if ext not in supported_ext:
|
||||||
continue
|
continue
|
||||||
m = meta.get(fname, {})
|
m = meta.get(fname, {})
|
||||||
if m.get("workflow_id") == job_id:
|
if m.get("workflow_id") == job_id:
|
||||||
matched_files.append(fname)
|
matched_files.append(fname)
|
||||||
elif str(uuid.uuid5(uuid.NAMESPACE_URL, fname)) == job_id:
|
|
||||||
matched_files.append(fname)
|
|
||||||
if not matched_files:
|
if not matched_files:
|
||||||
return web.json_response({"error": "not found"}, status=404)
|
return web.json_response({"error": "not found"}, status=404)
|
||||||
# 用最新文件作为代表
|
# 用最新文件作为代表
|
||||||
@@ -444,13 +452,13 @@ try:
|
|||||||
@PromptServer.instance.routes.post("/o1key/delete_history")
|
@PromptServer.instance.routes.post("/o1key/delete_history")
|
||||||
async def delete_history_item(request):
|
async def delete_history_item(request):
|
||||||
"""删除持久化历史记录及对应的输出文件"""
|
"""删除持久化历史记录及对应的输出文件"""
|
||||||
import os, uuid, json as _json
|
import os, json as _json
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
job_ids = body.get("delete", [])
|
job_ids = body.get("delete", [])
|
||||||
if not job_ids:
|
if not job_ids:
|
||||||
return web.json_response({"success": False, "error": "missing ids"}, status=400)
|
return web.json_response({"success": False, "error": "missing ids"}, status=400)
|
||||||
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
output_dir = os.path.abspath(folder_paths.get_output_directory())
|
||||||
meta_file = os.path.join(output_dir, ".o1key_history.json")
|
meta_file = _get_o1key_history_meta_file(output_dir)
|
||||||
meta = {}
|
meta = {}
|
||||||
if os.path.isfile(meta_file):
|
if os.path.isfile(meta_file):
|
||||||
try:
|
try:
|
||||||
@@ -464,8 +472,6 @@ try:
|
|||||||
for fname, m in list(meta.items()):
|
for fname, m in list(meta.items()):
|
||||||
if m.get("workflow_id") == job_id:
|
if m.get("workflow_id") == job_id:
|
||||||
files_to_remove.append(fname)
|
files_to_remove.append(fname)
|
||||||
elif str(uuid.uuid5(uuid.NAMESPACE_URL, fname)) == job_id:
|
|
||||||
files_to_remove.append(fname)
|
|
||||||
for fname in files_to_remove:
|
for fname in files_to_remove:
|
||||||
meta.pop(fname, None)
|
meta.pop(fname, None)
|
||||||
fpath = os.path.join(output_dir, fname)
|
fpath = os.path.join(output_dir, fname)
|
||||||
@@ -574,7 +580,7 @@ try:
|
|||||||
end_time = _time.time()
|
end_time = _time.time()
|
||||||
start_time = tracker["start"]
|
start_time = tracker["start"]
|
||||||
output_dir = _os.path.abspath(folder_paths.get_output_directory())
|
output_dir = _os.path.abspath(folder_paths.get_output_directory())
|
||||||
meta_file = _os.path.join(output_dir, ".o1key_history.json")
|
meta_file = _get_o1key_history_meta_file(output_dir)
|
||||||
meta = {}
|
meta = {}
|
||||||
if _os.path.isfile(meta_file):
|
if _os.path.isfile(meta_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+306
-60
@@ -6,9 +6,8 @@ GPT Image API 客户端
|
|||||||
|
|
||||||
设计原则:
|
设计原则:
|
||||||
- 与 doubao_image_client.py 保持相同的异步 + 同步双入口模式
|
- 与 doubao_image_client.py 保持相同的异步 + 同步双入口模式
|
||||||
- 图像以 multipart/form-data 方式上传(edits 接口)
|
- generations / edits 接口均使用 multipart/form-data
|
||||||
- generations 接口使用 JSON 请求体,图像以 data URI base64 内联传递
|
- 响应兼容 SSE 流式、JSON、url 和 b64_json
|
||||||
- 响应支持 url 和 b64_json 两种格式,优先处理 b64_json(避免二次下载)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -28,11 +27,6 @@ from ..utils.config import get_api_key_or_raise, get_api_base_url
|
|||||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR, get_friendly_message
|
from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR, get_friendly_message
|
||||||
|
|
||||||
# GPT Image 专属错误文案
|
|
||||||
_GPT_ERROR_MESSAGES = {
|
|
||||||
500: "触发内容风控,或服务器繁忙!",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
||||||
_INTERRUPT_AVAILABLE = True
|
_INTERRUPT_AVAILABLE = True
|
||||||
@@ -43,7 +37,7 @@ except ImportError:
|
|||||||
|
|
||||||
# ── 接口端点 ──────────────────────────────────────────────────────────────────
|
# ── 接口端点 ──────────────────────────────────────────────────────────────────
|
||||||
_ENDPOINT_GENERATIONS = "/v1/images/generations/"
|
_ENDPOINT_GENERATIONS = "/v1/images/generations/"
|
||||||
_ENDPOINT_EDITS = "/v1/images/edits/"
|
_ENDPOINT_EDITS = "/v1/images/edits"
|
||||||
|
|
||||||
# ── 模型名映射(UI 显示名 → API 实际参数名)─────────────────────────────────
|
# ── 模型名映射(UI 显示名 → API 实际参数名)─────────────────────────────────
|
||||||
_MODEL_NAME_MAP = {
|
_MODEL_NAME_MAP = {
|
||||||
@@ -60,11 +54,10 @@ class GptImageClient:
|
|||||||
GPT Image API 客户端
|
GPT Image API 客户端
|
||||||
|
|
||||||
接口说明:
|
接口说明:
|
||||||
generations:JSON body,支持 quality / size / n / model
|
generations:multipart/form-data,支持 quality / size / n / model
|
||||||
edits:multipart/form-data,必须包含 image(PNG),可选 mask(PNG)
|
edits:multipart/form-data,图片和 mask 使用 PNG 文件上传
|
||||||
|
|
||||||
两个接口的响应格式相同:
|
响应支持 JSON 和 SSE 流式格式。
|
||||||
{ "data": [ {"url": "..."} | {"b64_json": "..."} ] }
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -82,6 +75,26 @@ class GptImageClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _new_multipart_form() -> aiohttp.FormData:
|
||||||
|
try:
|
||||||
|
return aiohttp.FormData(default_to_multipart=True)
|
||||||
|
except TypeError:
|
||||||
|
form = aiohttp.FormData()
|
||||||
|
form._is_multipart = True
|
||||||
|
return form
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_form_fields(form: aiohttp.FormData, fields: dict) -> None:
|
||||||
|
for key, value in fields.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value = "true" if value else "false"
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
value = json.dumps(value, ensure_ascii=False)
|
||||||
|
form.add_field(key, str(value))
|
||||||
|
|
||||||
# ── 图像转换工具 ──────────────────────────────────────────────────────────
|
# ── 图像转换工具 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── 请求体大小限制 ────────────────────────────────────────────────────────
|
# ── 请求体大小限制 ────────────────────────────────────────────────────────
|
||||||
@@ -208,6 +221,10 @@ class GptImageClient:
|
|||||||
raise RuntimeError(f"API 返回错误: {msg}")
|
raise RuntimeError(f"API 返回错误: {msg}")
|
||||||
|
|
||||||
data_list = resp_json.get("data")
|
data_list = resp_json.get("data")
|
||||||
|
if data_list is None:
|
||||||
|
data_list = resp_json.get("images")
|
||||||
|
if data_list is None and (resp_json.get("b64_json") or resp_json.get("url")):
|
||||||
|
data_list = [resp_json]
|
||||||
if not data_list:
|
if not data_list:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"API 响应中未找到 data 字段,完整响应:\n"
|
f"API 响应中未找到 data 字段,完整响应:\n"
|
||||||
@@ -247,6 +264,203 @@ class GptImageClient:
|
|||||||
|
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_b64_image(b64: str, label: str) -> Image.Image:
|
||||||
|
try:
|
||||||
|
img_bytes = base64.b64decode(b64)
|
||||||
|
img = Image.open(BytesIO(img_bytes))
|
||||||
|
print(f"[o1key GPT Image] {label} base64 解码完成 ({img.size[0]}×{img.size[1]})")
|
||||||
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"{label} base64 解码失败: {e}") from None
|
||||||
|
|
||||||
|
async def _append_images_from_payload(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
images: List[Image.Image],
|
||||||
|
event_name: str = "",
|
||||||
|
) -> bool:
|
||||||
|
if isinstance(payload, dict) and "error" in payload:
|
||||||
|
err = payload["error"]
|
||||||
|
msg = (
|
||||||
|
err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
|
||||||
|
if isinstance(err, dict)
|
||||||
|
else str(err)
|
||||||
|
)
|
||||||
|
raise RuntimeError(get_friendly_message(500, msg)) from None
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
event_type = payload.get("type") or event_name
|
||||||
|
if "partial_image" in event_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for key in ("data", "images"):
|
||||||
|
data_list = payload.get(key)
|
||||||
|
if isinstance(data_list, list):
|
||||||
|
parsed = await self._parse_response({"data": data_list}, session)
|
||||||
|
images.extend(parsed)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if payload.get("b64_json") or payload.get("url"):
|
||||||
|
parsed = await self._parse_response({"data": [payload]}, session)
|
||||||
|
images.extend(parsed)
|
||||||
|
return True
|
||||||
|
|
||||||
|
image_obj = payload.get("image")
|
||||||
|
if isinstance(image_obj, dict) and (image_obj.get("b64_json") or image_obj.get("url")):
|
||||||
|
parsed = await self._parse_response({"data": [image_obj]}, session)
|
||||||
|
images.extend(parsed)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _parse_edit_stream_response(
|
||||||
|
self,
|
||||||
|
resp: aiohttp.ClientResponse,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
) -> List[Image.Image]:
|
||||||
|
"""
|
||||||
|
Parse /v1/images/edits SSE events and return final completed images.
|
||||||
|
Partial images are intentionally ignored so the node output stays unchanged.
|
||||||
|
"""
|
||||||
|
images: List[Image.Image] = []
|
||||||
|
buffer = ""
|
||||||
|
event_name = ""
|
||||||
|
data_lines = []
|
||||||
|
partial_count = 0
|
||||||
|
debug_body_parts = []
|
||||||
|
|
||||||
|
async def _handle_event():
|
||||||
|
nonlocal event_name, data_lines, partial_count, images
|
||||||
|
if not data_lines:
|
||||||
|
event_name = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
data_str = "\n".join(data_lines).strip()
|
||||||
|
event_name = event_name.strip()
|
||||||
|
data_lines = []
|
||||||
|
|
||||||
|
if not data_str or data_str == "[DONE]":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(data_str)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(get_friendly_message(500, data_str)) from None
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
event_type = payload.get("type") or event_name
|
||||||
|
else:
|
||||||
|
event_type = event_name
|
||||||
|
if "partial_image" in event_type:
|
||||||
|
partial_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._append_images_from_payload(payload, session, images, event_name)
|
||||||
|
|
||||||
|
async for raw_chunk in resp.content.iter_any():
|
||||||
|
chunk_text = raw_chunk.decode("utf-8", errors="ignore")
|
||||||
|
debug_body_parts.append(chunk_text)
|
||||||
|
buffer += chunk_text
|
||||||
|
while "\n" in buffer:
|
||||||
|
line, buffer = buffer.split("\n", 1)
|
||||||
|
line = line.rstrip("\r")
|
||||||
|
if line == "":
|
||||||
|
await _handle_event()
|
||||||
|
event_name = ""
|
||||||
|
continue
|
||||||
|
if line.startswith(":"):
|
||||||
|
continue
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_name = line[len("event:"):].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data_lines.append(line[len("data:"):].lstrip())
|
||||||
|
|
||||||
|
if buffer.strip():
|
||||||
|
data_lines.append(buffer.strip())
|
||||||
|
await _handle_event()
|
||||||
|
|
||||||
|
if partial_count:
|
||||||
|
print(f"[o1key GPT Image] 流式中间图 {partial_count} 张(已忽略,仅输出最终图)")
|
||||||
|
if not images:
|
||||||
|
raise RuntimeError("流式响应结束,但未收到最终图片")
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def _parse_stream_text_response(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
) -> List[Image.Image]:
|
||||||
|
images: List[Image.Image] = []
|
||||||
|
partial_count = 0
|
||||||
|
event_name = ""
|
||||||
|
data_lines = []
|
||||||
|
|
||||||
|
async def _handle_event():
|
||||||
|
nonlocal event_name, data_lines, partial_count, images
|
||||||
|
if not data_lines:
|
||||||
|
event_name = ""
|
||||||
|
return
|
||||||
|
data_str = "\n".join(data_lines).strip()
|
||||||
|
event_name = event_name.strip()
|
||||||
|
data_lines = []
|
||||||
|
if not data_str or data_str == "[DONE]":
|
||||||
|
return
|
||||||
|
payload = json.loads(data_str)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
event_type = payload.get("type") or event_name
|
||||||
|
else:
|
||||||
|
event_type = event_name
|
||||||
|
if "partial_image" in event_type:
|
||||||
|
partial_count += 1
|
||||||
|
return
|
||||||
|
await self._append_images_from_payload(payload, session, images, event_name)
|
||||||
|
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.rstrip("\r")
|
||||||
|
if line == "":
|
||||||
|
await _handle_event()
|
||||||
|
event_name = ""
|
||||||
|
continue
|
||||||
|
if line.startswith(":"):
|
||||||
|
continue
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_name = line[len("event:"):].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data_lines.append(line[len("data:"):].lstrip())
|
||||||
|
await _handle_event()
|
||||||
|
|
||||||
|
if partial_count:
|
||||||
|
print(f"[o1key GPT Image] 流式中间图 {partial_count} 张(已忽略,仅输出最终图)")
|
||||||
|
if not images:
|
||||||
|
raise RuntimeError("流式响应结束,但未收到最终图片")
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def _parse_success_response(
|
||||||
|
self,
|
||||||
|
resp: aiohttp.ClientResponse,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
label: str = "",
|
||||||
|
) -> List[Image.Image]:
|
||||||
|
content_type = resp.headers.get("Content-Type", "").lower()
|
||||||
|
if "event-stream" in content_type:
|
||||||
|
return await self._parse_edit_stream_response(resp, session)
|
||||||
|
|
||||||
|
text = await resp.text()
|
||||||
|
stripped = text.lstrip()
|
||||||
|
if stripped.startswith("data:") or stripped.startswith("event:"):
|
||||||
|
return await self._parse_stream_text_response(text, session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp_json = json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
|
||||||
|
return await self._parse_response(resp_json, session)
|
||||||
|
|
||||||
# ── 中断轮询 ──────────────────────────────────────────────────────────────
|
# ── 中断轮询 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -311,13 +525,15 @@ class GptImageClient:
|
|||||||
"quality": quality,
|
"quality": quality,
|
||||||
"n": n,
|
"n": n,
|
||||||
"moderation": "low",
|
"moderation": "low",
|
||||||
|
"partial_images": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
body["size"] = size if size else "auto"
|
body["size"] = size if size else "auto"
|
||||||
|
|
||||||
# 图生图:将 tensor 列表转成 data URI 内联
|
image_files = []
|
||||||
|
|
||||||
|
# 图生图:multipart 方式上传参考图
|
||||||
if image_list is not None:
|
if image_list is not None:
|
||||||
data_urls = []
|
|
||||||
for idx_img, img_tensor in enumerate(image_list):
|
for idx_img, img_tensor in enumerate(image_list):
|
||||||
pil_images = tensor_to_pil(img_tensor)
|
pil_images = tensor_to_pil(img_tensor)
|
||||||
img = pil_images[0]
|
img = pil_images[0]
|
||||||
@@ -333,10 +549,8 @@ class GptImageClient:
|
|||||||
png_budget = int(per_image_budget * 3 / 4)
|
png_budget = int(per_image_budget * 3 / 4)
|
||||||
label = f"第{idx_img + 1}张" if len(image_list) > 1 else ""
|
label = f"第{idx_img + 1}张" if len(image_list) > 1 else ""
|
||||||
png_bytes = self._shrink_png_to_limit(png_bytes, png_budget, label)
|
png_bytes = self._shrink_png_to_limit(png_bytes, png_budget, label)
|
||||||
b64 = base64.b64encode(png_bytes).decode("utf-8")
|
image_files.append(png_bytes)
|
||||||
data_urls.append(f"data:image/png;base64,{b64}")
|
mode = f"图生图(参考图 {len(image_files)} 张)"
|
||||||
body["image"] = data_urls[0] if len(data_urls) == 1 else data_urls
|
|
||||||
mode = f"图生图(参考图 {len(data_urls)} 张)"
|
|
||||||
else:
|
else:
|
||||||
mode = "文生图"
|
mode = "文生图"
|
||||||
|
|
||||||
@@ -347,25 +561,40 @@ class GptImageClient:
|
|||||||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
def _build_multipart_form() -> aiohttp.FormData:
|
||||||
|
form = self._new_multipart_form()
|
||||||
|
self._add_form_fields(form, body)
|
||||||
|
image_field = "image[]" if len(image_files) > 1 else "image"
|
||||||
|
for idx_img, png_bytes in enumerate(image_files):
|
||||||
|
form.add_field(
|
||||||
|
image_field,
|
||||||
|
png_bytes,
|
||||||
|
filename=f"image_{idx_img + 1}.png",
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
return form
|
||||||
|
|
||||||
async def _do_request():
|
async def _do_request():
|
||||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||||
last_status = None
|
last_status = None
|
||||||
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
async with session.post(url, json=body, headers=self._json_headers()) as resp:
|
async with session.post(
|
||||||
|
url,
|
||||||
|
data=_build_multipart_form(),
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
) as resp:
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
text = await resp.text()
|
|
||||||
|
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
last_status = resp.status
|
last_status = resp.status
|
||||||
|
text = await resp.text()
|
||||||
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
|
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
|
||||||
friendly = HTTP_ERROR_MESSAGES.get(resp.status)
|
friendly = get_friendly_message(resp.status)
|
||||||
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
|
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
|
||||||
print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
|
print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
if resp.status in _GPT_ERROR_MESSAGES:
|
|
||||||
raise RuntimeError(_GPT_ERROR_MESSAGES[resp.status])
|
|
||||||
if resp.status in HTTP_ERROR_MESSAGES:
|
if resp.status in HTTP_ERROR_MESSAGES:
|
||||||
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
|
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
|
||||||
try:
|
try:
|
||||||
@@ -380,13 +609,8 @@ class GptImageClient:
|
|||||||
msg = text
|
msg = text
|
||||||
raise RuntimeError(get_friendly_message(resp.status, msg))
|
raise RuntimeError(get_friendly_message(resp.status, msg))
|
||||||
|
|
||||||
try:
|
|
||||||
resp_json = json.loads(text)
|
|
||||||
except Exception:
|
|
||||||
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
|
|
||||||
|
|
||||||
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
||||||
return await self._parse_response(resp_json, session)
|
return await self._parse_success_response(resp, session, "GENERATIONS")
|
||||||
|
|
||||||
if last_status and last_status in HTTP_ERROR_MESSAGES:
|
if last_status and last_status in HTTP_ERROR_MESSAGES:
|
||||||
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
|
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
|
||||||
@@ -394,7 +618,7 @@ class GptImageClient:
|
|||||||
|
|
||||||
return await self._run_with_interrupt(_do_request())
|
return await self._run_with_interrupt(_do_request())
|
||||||
|
|
||||||
# ── 图像编辑(edits 接口,multipart/form-data)──────────────────────────
|
# ── 图像编辑(edits 接口,multipart/form-data)──────────────────────
|
||||||
|
|
||||||
async def _edit_async(
|
async def _edit_async(
|
||||||
self,
|
self,
|
||||||
@@ -408,7 +632,7 @@ class GptImageClient:
|
|||||||
mask_tensor: Optional[torch.Tensor] = None,
|
mask_tensor: Optional[torch.Tensor] = None,
|
||||||
) -> List[Image.Image]:
|
) -> List[Image.Image]:
|
||||||
"""
|
"""
|
||||||
调用 /v1/images/edits/ 接口(multipart/form-data)。
|
调用 /v1/images/edits 接口(multipart/form-data)。
|
||||||
"""
|
"""
|
||||||
# 模型名映射:UI 显示名 → API 参数名
|
# 模型名映射:UI 显示名 → API 参数名
|
||||||
api_model = _MODEL_NAME_MAP.get(model, model)
|
api_model = _MODEL_NAME_MAP.get(model, model)
|
||||||
@@ -421,15 +645,9 @@ class GptImageClient:
|
|||||||
normalized_tensors.append(t)
|
normalized_tensors.append(t)
|
||||||
num_images = len(normalized_tensors)
|
num_images = len(normalized_tensors)
|
||||||
|
|
||||||
form = aiohttp.FormData()
|
image_files = []
|
||||||
form.add_field("model", api_model)
|
|
||||||
form.add_field("prompt", prompt)
|
|
||||||
form.add_field("n", str(n))
|
|
||||||
form.add_field("quality", quality)
|
|
||||||
|
|
||||||
form.add_field("size", size if size else "auto")
|
# 多图:用 multipart image/image[] 字段逐张上传
|
||||||
|
|
||||||
# 多图:用 image[] 数组字段逐张附加,支持 gpt-image-1.5 最多 16 张
|
|
||||||
# 预算:20MB 按图数平摊,蒙版预留 1MB
|
# 预算:20MB 按图数平摊,蒙版预留 1MB
|
||||||
mask_reserve = 1024 * 1024 if mask_tensor is not None else 0
|
mask_reserve = 1024 * 1024 if mask_tensor is not None else 0
|
||||||
per_image_budget = max(
|
per_image_budget = max(
|
||||||
@@ -440,28 +658,50 @@ class GptImageClient:
|
|||||||
img_bytes = self._tensor_to_png_bytes(frame)
|
img_bytes = self._tensor_to_png_bytes(frame)
|
||||||
label = f"第{i + 1}张" if num_images > 1 else ""
|
label = f"第{i + 1}张" if num_images > 1 else ""
|
||||||
img_bytes = self._shrink_png_to_limit(img_bytes, per_image_budget, label)
|
img_bytes = self._shrink_png_to_limit(img_bytes, per_image_budget, label)
|
||||||
form.add_field(
|
image_files.append(img_bytes)
|
||||||
"image[]",
|
|
||||||
img_bytes,
|
|
||||||
filename=f"image_{i}.png",
|
|
||||||
content_type="image/png",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 蒙版尺寸校验以第一张图为基准
|
# 蒙版尺寸校验以第一张图为基准
|
||||||
first_tensor = normalized_tensors[0]
|
first_tensor = normalized_tensors[0]
|
||||||
ih, iw = first_tensor.shape[1], first_tensor.shape[2]
|
ih, iw = first_tensor.shape[1], first_tensor.shape[2]
|
||||||
|
|
||||||
|
mask_png = None
|
||||||
if mask_tensor is not None:
|
if mask_tensor is not None:
|
||||||
mask_png = self._mask_tensor_to_rgba_png_bytes(mask_tensor, (ih, iw))
|
mask_png = self._mask_tensor_to_rgba_png_bytes(mask_tensor, (ih, iw))
|
||||||
|
mode = "图像编辑(带蒙版)"
|
||||||
|
else:
|
||||||
|
mode = "图像编辑(无蒙版)"
|
||||||
|
|
||||||
|
form_fields = {
|
||||||
|
"model": api_model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"partial_images": 0,
|
||||||
|
"n": n,
|
||||||
|
"quality": quality,
|
||||||
|
"size": size if size else "auto",
|
||||||
|
"output_format": "png",
|
||||||
|
"background": "opaque",
|
||||||
|
"moderation": "low",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_multipart_form() -> aiohttp.FormData:
|
||||||
|
form = self._new_multipart_form()
|
||||||
|
self._add_form_fields(form, form_fields)
|
||||||
|
|
||||||
|
for idx_img, img_bytes in enumerate(image_files):
|
||||||
|
form.add_field(
|
||||||
|
"image[]",
|
||||||
|
img_bytes,
|
||||||
|
filename=f"image_{idx_img + 1}.png",
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
if mask_png is not None:
|
||||||
form.add_field(
|
form.add_field(
|
||||||
"mask",
|
"mask",
|
||||||
mask_png,
|
mask_png,
|
||||||
filename="mask.png",
|
filename="mask.png",
|
||||||
content_type="image/png",
|
content_type="image/png",
|
||||||
)
|
)
|
||||||
mode = "图像编辑(带蒙版)"
|
return form
|
||||||
else:
|
|
||||||
mode = "图像编辑(无蒙版)"
|
|
||||||
|
|
||||||
url = f"{self.base_url}{_ENDPOINT_EDITS}"
|
url = f"{self.base_url}{_ENDPOINT_EDITS}"
|
||||||
print(f"[o1key GPT Image] {mode} | 模型={model} | 参考图={num_images}张 | "
|
print(f"[o1key GPT Image] {mode} | 模型={model} | 参考图={num_images}张 | "
|
||||||
@@ -472,18 +712,25 @@ class GptImageClient:
|
|||||||
|
|
||||||
async def _do_request():
|
async def _do_request():
|
||||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||||
|
last_status = None
|
||||||
|
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
async with session.post(
|
async with session.post(
|
||||||
url,
|
url,
|
||||||
data=form,
|
data=_build_multipart_form(),
|
||||||
headers=self._auth_headers(),
|
headers=self._auth_headers(),
|
||||||
) as resp:
|
) as resp:
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
text = await resp.text()
|
|
||||||
|
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
if resp.status in _GPT_ERROR_MESSAGES:
|
text = await resp.text()
|
||||||
raise RuntimeError(_GPT_ERROR_MESSAGES[resp.status])
|
last_status = resp.status
|
||||||
|
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
|
||||||
|
friendly = get_friendly_message(resp.status)
|
||||||
|
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
|
||||||
|
print(f"[o1key GPT Image] {friendly} retrying in {delay:.1f}s ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
if resp.status in HTTP_ERROR_MESSAGES:
|
if resp.status in HTTP_ERROR_MESSAGES:
|
||||||
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
|
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
|
||||||
try:
|
try:
|
||||||
@@ -498,13 +745,12 @@ class GptImageClient:
|
|||||||
msg = text
|
msg = text
|
||||||
raise RuntimeError(get_friendly_message(resp.status, msg))
|
raise RuntimeError(get_friendly_message(resp.status, msg))
|
||||||
|
|
||||||
try:
|
|
||||||
resp_json = json.loads(text)
|
|
||||||
except Exception:
|
|
||||||
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
|
|
||||||
|
|
||||||
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
||||||
return await self._parse_response(resp_json, session)
|
return await self._parse_success_response(resp, session, "EDITS")
|
||||||
|
|
||||||
|
if last_status and last_status in HTTP_ERROR_MESSAGES:
|
||||||
|
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
|
||||||
|
raise RuntimeError(f"Request failed after {DEFAULT_MAX_RETRIES} retries")
|
||||||
|
|
||||||
return await self._run_with_interrupt(_do_request())
|
return await self._run_with_interrupt(_do_request())
|
||||||
|
|
||||||
@@ -525,7 +771,7 @@ class GptImageClient:
|
|||||||
同步入口,在独立线程中运行事件循环,避免与 ComfyUI 主循环冲突。
|
同步入口,在独立线程中运行事件循环,避免与 ComfyUI 主循环冲突。
|
||||||
|
|
||||||
路由逻辑:
|
路由逻辑:
|
||||||
- 无 image_tensor → generations 接口(文生图,JSON body)
|
- 无 image_tensor → generations 接口(文生图,multipart/form-data)
|
||||||
- 有 image_tensor → edits 接口(图生图/编辑,multipart/form-data)
|
- 有 image_tensor → edits 接口(图生图/编辑,multipart/form-data)
|
||||||
"""
|
"""
|
||||||
use_edits = (image_tensor is not None)
|
use_edits = (image_tensor is not None)
|
||||||
|
|||||||
+37
-37
@@ -11,6 +11,16 @@ import aiohttp
|
|||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KlingClient:
|
class KlingClient:
|
||||||
@@ -50,9 +60,11 @@ class KlingClient:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
url = f"{self.base_url}{self.ENDPOINTS[endpoint_type]}"
|
url = f"{self.base_url}{self.ENDPOINTS[endpoint_type]}"
|
||||||
|
|
||||||
resp = await async_request_with_retry(
|
check_interrupt()
|
||||||
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", url, json=body, headers=self._headers(), prefix="Kling 提交: "
|
session, "POST", url, json=body, headers=self._headers(), prefix="Kling 提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
|
|
||||||
@@ -69,6 +81,7 @@ class KlingClient:
|
|||||||
interval = self.POLL_INITIAL_INTERVAL
|
interval = self.POLL_INITIAL_INTERVAL
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(url, headers=self._headers()) as resp:
|
async with session.get(url, headers=self._headers()) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -77,36 +90,22 @@ class KlingClient:
|
|||||||
|
|
||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
inner_data = data.get("data", {}) if isinstance(data, dict) else {}
|
inner_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||||
status = (
|
status = extract_status(result)
|
||||||
data.get("status") or
|
|
||||||
inner_data.get("task_status") or
|
|
||||||
result.get("status") or
|
|
||||||
""
|
|
||||||
)
|
|
||||||
status = status.lower() if status else ""
|
|
||||||
|
|
||||||
progress_str = data.get("progress", "0%")
|
progress_pct = extract_progress(result)
|
||||||
try:
|
|
||||||
progress_pct = int(str(progress_str).replace("%", "").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
progress_pct = 0
|
|
||||||
|
|
||||||
print(f"[视频生成] 生成中 {progress_pct}%")
|
print(f"[视频生成] 生成中 {progress_pct}%")
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress(progress_pct)
|
on_progress(progress_pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
return result
|
return result
|
||||||
elif status in ("failed", "fail"):
|
elif is_failure_status(status, result):
|
||||||
error_info = result.get("error", {})
|
error_msg = extract_error_message(result)
|
||||||
if isinstance(error_info, dict):
|
|
||||||
error_msg = error_info.get("message", "未知错误")
|
|
||||||
else:
|
|
||||||
error_msg = str(error_info)
|
|
||||||
raise RuntimeError(f"生成失败:{error_msg}")
|
raise RuntimeError(f"生成失败:{error_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
||||||
|
|
||||||
# ── 下载视频 ──────────────────────────────────────────────────────
|
# ── 下载视频 ──────────────────────────────────────────────────────
|
||||||
@@ -118,12 +117,14 @@ class KlingClient:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
) -> str:
|
) -> str:
|
||||||
print("[视频生成] 下载视频...")
|
print("[视频生成] 下载视频...")
|
||||||
|
check_interrupt()
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
return save_path
|
return save_path
|
||||||
|
|
||||||
@@ -201,12 +202,14 @@ class KlingClient:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交
|
# 1. 提交
|
||||||
|
check_interrupt()
|
||||||
if on_stage:
|
if on_stage:
|
||||||
on_stage("submitting")
|
on_stage("submitting")
|
||||||
create_url = f"{self.base_url}{self.NEW_API_CREATE}"
|
create_url = f"{self.base_url}{self.NEW_API_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url, json=body, headers=headers, prefix="Kling 动作控制提交: "
|
session, "POST", create_url, json=body, headers=headers, prefix="Kling 动作控制提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
create_resp = json.loads(text)
|
create_resp = json.loads(text)
|
||||||
|
|
||||||
@@ -219,6 +222,7 @@ class KlingClient:
|
|||||||
# 2. 轮询
|
# 2. 轮询
|
||||||
status_url = f"{self.base_url}{self.NEW_API_STATUS.format(video_id=video_id)}"
|
status_url = f"{self.base_url}{self.NEW_API_STATUS.format(video_id=video_id)}"
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -230,29 +234,24 @@ class KlingClient:
|
|||||||
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
|
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
|
||||||
status_resp = json.loads(text)
|
status_resp = json.loads(text)
|
||||||
|
|
||||||
status = status_resp.get("status", "").lower()
|
status = extract_status(status_resp)
|
||||||
progress_raw = status_resp.get("progress", 0)
|
progress_pct = extract_progress(status_resp)
|
||||||
try:
|
|
||||||
progress_pct = int(str(progress_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
progress_pct = 0
|
|
||||||
|
|
||||||
print(f"[动作控制] 生成中 {progress_pct}%")
|
print(f"[动作控制] 生成中 {progress_pct}%")
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress(progress_pct)
|
on_progress(progress_pct)
|
||||||
|
|
||||||
if status == "completed":
|
if is_success_status(status):
|
||||||
break
|
break
|
||||||
if status == "failed":
|
if is_failure_status(status, status_resp):
|
||||||
error_info = status_resp.get("error", {})
|
error_msg = extract_error_message(status_resp)
|
||||||
error_msg = (error_info.get("message", "未知错误")
|
|
||||||
if isinstance(error_info, dict) else str(error_info))
|
|
||||||
raise RuntimeError(f"动作控制生成失败:{error_msg}")
|
raise RuntimeError(f"动作控制生成失败:{error_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
||||||
|
|
||||||
# 3. 下载
|
# 3. 下载
|
||||||
|
check_interrupt()
|
||||||
if on_stage:
|
if on_stage:
|
||||||
on_stage("downloading")
|
on_stage("downloading")
|
||||||
content_url = f"{self.base_url}{self.NEW_API_CONTENT.format(video_id=video_id)}"
|
content_url = f"{self.base_url}{self.NEW_API_CONTENT.format(video_id=video_id)}"
|
||||||
@@ -272,14 +271,15 @@ class KlingClient:
|
|||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in dl_resp.content.iter_chunked(8192):
|
async for chunk in dl_resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
else:
|
else:
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
if on_stage:
|
if on_stage:
|
||||||
on_stage("done")
|
on_stage("done")
|
||||||
return save_path
|
return save_path
|
||||||
|
|
||||||
|
|||||||
+24
-16
@@ -12,6 +12,16 @@ import aiohttp
|
|||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise
|
from ..utils.config import get_api_key_or_raise
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SeedanceClient:
|
class SeedanceClient:
|
||||||
@@ -48,9 +58,11 @@ class SeedanceClient:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""提交视频生成任务,返回 task_id"""
|
"""提交视频生成任务,返回 task_id"""
|
||||||
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
|
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
|
||||||
resp = await async_request_with_retry(
|
check_interrupt()
|
||||||
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", url, json=body, headers=self._headers(), prefix="Seedance 提交: "
|
session, "POST", url, json=body, headers=self._headers(), prefix="Seedance 提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
|
|
||||||
@@ -73,6 +85,7 @@ class SeedanceClient:
|
|||||||
interval = self.POLL_INITIAL_INTERVAL
|
interval = self.POLL_INITIAL_INTERVAL
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(url, headers=self._headers()) as resp:
|
async with session.get(url, headers=self._headers()) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -89,20 +102,16 @@ class SeedanceClient:
|
|||||||
# new-api 包装格式:真实数据在 result["data"] 里
|
# new-api 包装格式:真实数据在 result["data"] 里
|
||||||
inner = result.get("data") or result
|
inner = result.get("data") or result
|
||||||
|
|
||||||
status = (inner.get("status") or "").lower()
|
status = extract_status(result)
|
||||||
|
|
||||||
# 解析进度
|
# 解析进度
|
||||||
progress_raw = inner.get("progress", "0")
|
progress_pct = extract_progress(result)
|
||||||
try:
|
|
||||||
progress_pct = int(str(progress_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
progress_pct = 0
|
|
||||||
|
|
||||||
print(f"[Seedance] 生成中 {progress_pct}%")
|
print(f"[Seedance] 生成中 {progress_pct}%")
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress(progress_pct)
|
on_progress(progress_pct)
|
||||||
|
|
||||||
if status in self.SUCCESS_STATUSES:
|
if is_success_status(status):
|
||||||
# 响应结构:result["data"] = inner,inner["data"] = platform_data
|
# 响应结构:result["data"] = inner,inner["data"] = platform_data
|
||||||
# 视频 URL 在 inner["result_url"] 或 inner["data"]["content"]["video_url"]
|
# 视频 URL 在 inner["result_url"] 或 inner["data"]["content"]["video_url"]
|
||||||
platform_data = inner.get("data") or {}
|
platform_data = inner.get("data") or {}
|
||||||
@@ -123,15 +132,11 @@ class SeedanceClient:
|
|||||||
)
|
)
|
||||||
return video_url, last_frame_url
|
return video_url, last_frame_url
|
||||||
|
|
||||||
if status in self.FAILURE_STATUSES:
|
if is_failure_status(status, result):
|
||||||
reason = (
|
reason = extract_error_message(result)
|
||||||
inner.get("fail_reason")
|
|
||||||
or (inner.get("error") or {}).get("message")
|
|
||||||
or "未知错误"
|
|
||||||
)
|
|
||||||
raise RuntimeError(f"视频生成失败:{reason}")
|
raise RuntimeError(f"视频生成失败:{reason}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
|
||||||
|
|
||||||
# ── 3. 下载视频 ────────────────────────────────────────────────────
|
# ── 3. 下载视频 ────────────────────────────────────────────────────
|
||||||
@@ -144,12 +149,14 @@ class SeedanceClient:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""下载视频到本地,返回本地路径"""
|
"""下载视频到本地,返回本地路径"""
|
||||||
print(f"[Seedance] 下载视频...")
|
print(f"[Seedance] 下载视频...")
|
||||||
|
check_interrupt()
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
return save_path
|
return save_path
|
||||||
|
|
||||||
@@ -167,6 +174,7 @@ class SeedanceClient:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 提交
|
# 提交
|
||||||
|
check_interrupt()
|
||||||
if on_stage:
|
if on_stage:
|
||||||
on_stage("submitting")
|
on_stage("submitting")
|
||||||
task_id = await self.submit_async(body, session)
|
task_id = await self.submit_async(body, session)
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>O1Key 笔记侧栏草图</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #151515;
|
||||||
|
--rail: #1b1b1b;
|
||||||
|
--panel: #202020;
|
||||||
|
--panel-2: #262626;
|
||||||
|
--field: #181818;
|
||||||
|
--line: rgba(255,255,255,.09);
|
||||||
|
--line-2: rgba(255,255,255,.16);
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--soft: #b5b5b5;
|
||||||
|
--muted: #777;
|
||||||
|
--blue: #4f8cff;
|
||||||
|
--blue-2: #7eb8f7;
|
||||||
|
--blue-soft: rgba(79,140,255,.14);
|
||||||
|
--danger: #d76565;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #101010;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
width: 1180px;
|
||||||
|
height: 740px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid #303030;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px 352px 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 60px rgba(0,0,0,.45);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
background: var(--rail);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 10px 7px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8a8a8a;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail button.active {
|
||||||
|
color: var(--blue-2);
|
||||||
|
background: rgba(79,140,255,.13);
|
||||||
|
border-color: rgba(79,140,255,.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row,
|
||||||
|
.actions,
|
||||||
|
.tag-strip,
|
||||||
|
.note-top,
|
||||||
|
.note-meta,
|
||||||
|
.edit-top,
|
||||||
|
.edit-actions,
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { gap: 5px; }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #9b9b9b;
|
||||||
|
background: rgba(255,255,255,.035);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.primary {
|
||||||
|
color: white;
|
||||||
|
background: var(--blue);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,.045);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-strip {
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: #969696;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter.active {
|
||||||
|
color: var(--blue-2);
|
||||||
|
border-color: rgba(79,140,255,.36);
|
||||||
|
background: var(--blue-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-hint {
|
||||||
|
height: 24px;
|
||||||
|
color: #606060;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.04);
|
||||||
|
margin: 2px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.active {
|
||||||
|
background: rgba(255,255,255,.055);
|
||||||
|
border-color: rgba(255,255,255,.11);
|
||||||
|
box-shadow: inset 2px 0 0 var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action {
|
||||||
|
color: #777;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #8c8c8c;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #666;
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
color: var(--blue-2);
|
||||||
|
background: rgba(79,140,255,.1);
|
||||||
|
border: 1px solid rgba(79,140,255,.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
top: 22px;
|
||||||
|
color: #5f5f5f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
position: absolute;
|
||||||
|
width: 186px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
background: #222;
|
||||||
|
box-shadow: 0 12px 30px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.one { left: 160px; top: 160px; }
|
||||||
|
.node.two { left: 430px; top: 290px; }
|
||||||
|
.node::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 28px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
background: rgba(79,140,255,.12);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-popover {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
top: 76px;
|
||||||
|
width: 420px;
|
||||||
|
height: 588px;
|
||||||
|
border: 1px solid rgba(79,140,255,.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #202020;
|
||||||
|
box-shadow: 0 22px 70px rgba(0,0,0,.46);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-top {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-body {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,.045);
|
||||||
|
color: #ddd;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-editor {
|
||||||
|
min-height: 74px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--field);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-token {
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid rgba(79,140,255,.25);
|
||||||
|
color: var(--blue-2);
|
||||||
|
background: rgba(79,140,255,.1);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-token .x {
|
||||||
|
color: #8baee8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input {
|
||||||
|
height: 24px;
|
||||||
|
min-width: 106px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px dashed rgba(255,255,255,.14);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8f8f8f;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.08);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,.04);
|
||||||
|
color: #aaa;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--blue);
|
||||||
|
border-color: transparent;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
color: #ee9f9f;
|
||||||
|
border-color: rgba(215,101,101,.24);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<nav class="rail">
|
||||||
|
<button>☰</button>
|
||||||
|
<button>💬</button>
|
||||||
|
<button class="active">✎</button>
|
||||||
|
<button>🖼</button>
|
||||||
|
<button>⚙</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<aside class="note-panel">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title-row">
|
||||||
|
<div class="title">笔记</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="icon-btn">⇩</button>
|
||||||
|
<button class="icon-btn primary">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search">⌕ 搜索笔记内容或标签</div>
|
||||||
|
<div class="tag-strip">
|
||||||
|
<div class="tag-filter active">全部 18</div>
|
||||||
|
<div class="tag-filter">#产品图</div>
|
||||||
|
<div class="tag-filter">#Nano Banana</div>
|
||||||
|
<div class="tag-filter">#负向词</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
<div class="note active">
|
||||||
|
<div class="note-top">
|
||||||
|
<div class="note-title">产品主图:高级玻璃质感</div>
|
||||||
|
<div class="note-action">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-text">Clean studio lighting, translucent glass material, subtle caustics, soft shadow, premium product photography...</div>
|
||||||
|
<div class="note-meta"><div class="tags"><span class="tag">#产品图</span><span class="tag">#玻璃</span></div><span>今天 14:22</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<div class="note-top">
|
||||||
|
<div class="note-title">Nano Banana 参考图经验</div>
|
||||||
|
<div class="note-action">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-text">参考图越多越容易跑偏,主体一致性优先用 1-3 张图;复杂场景建议分两步...</div>
|
||||||
|
<div class="note-meta"><div class="tags"><span class="tag">#Nano Banana</span><span class="tag">#参考图</span></div><span>昨天</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<div class="note-top">
|
||||||
|
<div class="note-title">电商模特换装模板</div>
|
||||||
|
<div class="note-action">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-text">Keep face identity, preserve pose, replace outfit with [服装描述], realistic fabric texture...</div>
|
||||||
|
<div class="note-meta"><div class="tags"><span class="tag">#电商</span><span class="tag">#换装</span></div><span>05/25</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<div class="note-top">
|
||||||
|
<div class="note-title">常用负向词</div>
|
||||||
|
<div class="note-action">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-text">low quality, blurry, deformed hands, extra fingers, bad anatomy, distorted text, watermark...</div>
|
||||||
|
<div class="note-meta"><div class="tags"><span class="tag">#负向词</span><span class="tag">#通用</span></div><span>05/20</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<div class="note-top">
|
||||||
|
<div class="note-title">批量任务命名经验</div>
|
||||||
|
<div class="note-action">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-text">小批量先跑 2-3 张确认风格,固定提示词和参考图后再放大批量数量...</div>
|
||||||
|
<div class="note-meta"><div class="tags"><span class="tag">#批量</span><span class="tag">#工作流</span></div><span>05/18</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="scroll-hint">列表默认可滚动,编辑框不常驻</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="canvas">
|
||||||
|
<div class="canvas-label">ComfyUI 画布区域,笔记列表不再占用右侧空间</div>
|
||||||
|
<div class="node one"></div>
|
||||||
|
<div class="node two"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="edit-popover">
|
||||||
|
<div class="edit-top">
|
||||||
|
<div class="edit-title">编辑笔记</div>
|
||||||
|
<button class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-body">
|
||||||
|
<div class="input">产品主图:高级玻璃质感</div>
|
||||||
|
<div class="tag-editor">
|
||||||
|
<div class="tag-row">
|
||||||
|
<span class="tag-token">产品图 <span class="x">×</span></span>
|
||||||
|
<span class="tag-token">玻璃 <span class="x">×</span></span>
|
||||||
|
<span class="tag-token">灯光 <span class="x">×</span></span>
|
||||||
|
<span class="tag-input">+ 添加标签</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint">只保留标签作为组织方式;可搜索、筛选、删除。</div>
|
||||||
|
</div>
|
||||||
|
<div class="textarea">Clean studio lighting, translucent glass material, subtle caustics, soft shadow, premium product photography, 85mm lens, minimal background, high detail.
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
1. 把产品图作为参考图输入
|
||||||
|
2. 保留主体轮廓,只调整材质和灯光
|
||||||
|
3. 如果玻璃过亮,降低 “caustics” 权重</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="btn cancel">取消</button>
|
||||||
|
<button class="btn">复制</button>
|
||||||
|
<button class="btn danger">删除</button>
|
||||||
|
<button class="btn primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+27
-22
@@ -17,6 +17,17 @@ from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK
|
|||||||
from ..utils.r2_uploader import upload_video, upload_image
|
from ..utils.r2_uploader import upload_video, upload_image
|
||||||
from ..utils.image_utils import tensor_to_pil
|
from ..utils.image_utils import tensor_to_pil
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
extract_video_url,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy_api.latest import InputImpl
|
from comfy_api.latest import InputImpl
|
||||||
@@ -183,8 +194,10 @@ class K3MotionControl:
|
|||||||
|
|
||||||
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
|
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
|
||||||
_stage("uploading")
|
_stage("uploading")
|
||||||
|
check_interrupt()
|
||||||
pil_list = tensor_to_pil(参考图片)
|
pil_list = tensor_to_pil(参考图片)
|
||||||
image_url = await upload_image(pil_list[0].convert("RGB"))
|
image_url = await upload_image(pil_list[0].convert("RGB"))
|
||||||
|
check_interrupt()
|
||||||
video_url = await upload_video(参考视频)
|
video_url = await upload_video(参考视频)
|
||||||
|
|
||||||
# ── 构建请求体 ────────────────────────────────────────────────
|
# ── 构建请求体 ────────────────────────────────────────────────
|
||||||
@@ -207,13 +220,15 @@ class K3MotionControl:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交任务
|
# 1. 提交任务
|
||||||
|
check_interrupt()
|
||||||
_stage("submitting")
|
_stage("submitting")
|
||||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url,
|
session, "POST", create_url,
|
||||||
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||||
headers=headers, prefix="K3 动作控制提交: "
|
headers=headers, prefix="K3 动作控制提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
create_resp = json.loads(text)
|
create_resp = json.loads(text)
|
||||||
|
|
||||||
@@ -233,7 +248,8 @@ class K3MotionControl:
|
|||||||
video_result_url = None
|
video_result_url = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -247,30 +263,17 @@ class K3MotionControl:
|
|||||||
|
|
||||||
# 兼容扁平结构和 data 嵌套结构
|
# 兼容扁平结构和 data 嵌套结构
|
||||||
data = sr.get("data", sr)
|
data = sr.get("data", sr)
|
||||||
status = (data.get("status") or sr.get("status") or "").lower()
|
status = extract_status(sr)
|
||||||
|
|
||||||
pct_raw = data.get("progress", 0)
|
pct = extract_progress(sr)
|
||||||
try:
|
|
||||||
pct = int(str(pct_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pct = 0
|
|
||||||
print(f"[K3 动作控制] 生成中 {pct}%")
|
print(f"[K3 动作控制] 生成中 {pct}%")
|
||||||
_progress(pct)
|
_progress(pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
video_result_url = (
|
video_result_url = extract_video_url(sr)
|
||||||
data.get("video_url")
|
|
||||||
or data.get("result_url")
|
|
||||||
or data.get("url")
|
|
||||||
or (data.get("result", {}) or {}).get("url")
|
|
||||||
or sr.get("video_url")
|
|
||||||
or sr.get("url")
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
elif status in ("failed", "fail"):
|
elif is_failure_status(status, sr):
|
||||||
err_info = data.get("error") or sr.get("error") or {}
|
err_msg = extract_error_message(sr)
|
||||||
err_msg = (err_info.get("message", "未知错误")
|
|
||||||
if isinstance(err_info, dict) else str(err_info))
|
|
||||||
raise RuntimeError(f"K3 动作控制生成失败:{err_msg}")
|
raise RuntimeError(f"K3 动作控制生成失败:{err_msg}")
|
||||||
|
|
||||||
interval = min(interval * 1.3, _POLL_MAX)
|
interval = min(interval * 1.3, _POLL_MAX)
|
||||||
@@ -279,6 +282,7 @@ class K3MotionControl:
|
|||||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||||
|
|
||||||
# 3. 下载视频
|
# 3. 下载视频
|
||||||
|
check_interrupt()
|
||||||
_stage("downloading")
|
_stage("downloading")
|
||||||
async with session.get(video_result_url, allow_redirects=True) as resp:
|
async with session.get(video_result_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -286,6 +290,7 @@ class K3MotionControl:
|
|||||||
os.close(tmp_fd)
|
os.close(tmp_fd)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
_stage("done")
|
_stage("done")
|
||||||
|
|||||||
+25
-22
@@ -14,6 +14,17 @@ import aiohttp
|
|||||||
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
extract_video_url,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy_api.latest import InputImpl
|
from comfy_api.latest import InputImpl
|
||||||
@@ -246,11 +257,13 @@ class K3Video:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交
|
# 1. 提交
|
||||||
|
check_interrupt()
|
||||||
_stage("submitting")
|
_stage("submitting")
|
||||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url, json=body, headers=headers, prefix="K3 提交: "
|
session, "POST", create_url, json=body, headers=headers, prefix="K3 提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
create_resp = json.loads(text)
|
create_resp = json.loads(text)
|
||||||
|
|
||||||
@@ -269,6 +282,7 @@ class K3Video:
|
|||||||
video_url = None
|
video_url = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -281,39 +295,27 @@ class K3Video:
|
|||||||
sr = json.loads(text)
|
sr = json.loads(text)
|
||||||
|
|
||||||
data = sr.get("data", sr)
|
data = sr.get("data", sr)
|
||||||
status = (data.get("status") or sr.get("status") or "").lower()
|
status = extract_status(sr)
|
||||||
|
|
||||||
pct_raw = data.get("progress", 0)
|
pct = extract_progress(sr)
|
||||||
try:
|
|
||||||
pct = int(str(pct_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pct = 0
|
|
||||||
print(f"[K3 {tag}] 生成中 {pct}%")
|
print(f"[K3 {tag}] 生成中 {pct}%")
|
||||||
_progress(pct)
|
_progress(pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
video_url = (
|
video_url = extract_video_url(sr)
|
||||||
data.get("video_url")
|
|
||||||
or data.get("result_url")
|
|
||||||
or data.get("url")
|
|
||||||
or (data.get("result", {}) or {}).get("url")
|
|
||||||
or sr.get("video_url")
|
|
||||||
or sr.get("url")
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
if status in ("failed", "fail"):
|
if is_failure_status(status, sr):
|
||||||
err_info = data.get("error") or sr.get("error") or {}
|
err_msg = extract_error_message(sr)
|
||||||
err_msg = (err_info.get("message", "未知错误")
|
|
||||||
if isinstance(err_info, dict) else str(err_info))
|
|
||||||
raise RuntimeError(f"K3 生成失败:{err_msg}")
|
raise RuntimeError(f"K3 生成失败:{err_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, _POLL_MAX)
|
interval = min(interval * 1.5, _POLL_MAX)
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||||
|
|
||||||
# 3. 下载
|
# 3. 下载
|
||||||
|
check_interrupt()
|
||||||
_stage("downloading")
|
_stage("downloading")
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -321,6 +323,7 @@ class K3Video:
|
|||||||
os.close(tmp_fd)
|
os.close(tmp_fd)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
_stage("done")
|
_stage("done")
|
||||||
|
|||||||
+25
-22
@@ -13,6 +13,17 @@ import aiohttp
|
|||||||
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
extract_video_url,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy_api.latest import InputImpl
|
from comfy_api.latest import InputImpl
|
||||||
@@ -185,11 +196,13 @@ class K3VideoFirstLast:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交
|
# 1. 提交
|
||||||
|
check_interrupt()
|
||||||
_stage("submitting")
|
_stage("submitting")
|
||||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url, json=body, headers=headers, prefix="K3 首尾帧提交: "
|
session, "POST", create_url, json=body, headers=headers, prefix="K3 首尾帧提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
create_resp = json.loads(text)
|
create_resp = json.loads(text)
|
||||||
|
|
||||||
@@ -208,6 +221,7 @@ class K3VideoFirstLast:
|
|||||||
video_url = None
|
video_url = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -220,39 +234,27 @@ class K3VideoFirstLast:
|
|||||||
sr = json.loads(text)
|
sr = json.loads(text)
|
||||||
|
|
||||||
data = sr.get("data", sr)
|
data = sr.get("data", sr)
|
||||||
status = (data.get("status") or sr.get("status") or "").lower()
|
status = extract_status(sr)
|
||||||
|
|
||||||
pct_raw = data.get("progress", 0)
|
pct = extract_progress(sr)
|
||||||
try:
|
|
||||||
pct = int(str(pct_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pct = 0
|
|
||||||
print(f"[K3 首尾帧] 生成中 {pct}%")
|
print(f"[K3 首尾帧] 生成中 {pct}%")
|
||||||
_progress(pct)
|
_progress(pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
video_url = (
|
video_url = extract_video_url(sr)
|
||||||
data.get("video_url")
|
|
||||||
or data.get("result_url")
|
|
||||||
or data.get("url")
|
|
||||||
or (data.get("result", {}) or {}).get("url")
|
|
||||||
or sr.get("video_url")
|
|
||||||
or sr.get("url")
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
if status in ("failed", "fail"):
|
if is_failure_status(status, sr):
|
||||||
err_info = data.get("error") or sr.get("error") or {}
|
err_msg = extract_error_message(sr)
|
||||||
err_msg = (err_info.get("message", "未知错误")
|
|
||||||
if isinstance(err_info, dict) else str(err_info))
|
|
||||||
raise RuntimeError(f"K3 首尾帧生成失败:{err_msg}")
|
raise RuntimeError(f"K3 首尾帧生成失败:{err_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, _POLL_MAX)
|
interval = min(interval * 1.5, _POLL_MAX)
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||||
|
|
||||||
# 3. 下载
|
# 3. 下载
|
||||||
|
check_interrupt()
|
||||||
_stage("downloading")
|
_stage("downloading")
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -260,6 +262,7 @@ class K3VideoFirstLast:
|
|||||||
os.close(tmp_fd)
|
os.close(tmp_fd)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
_stage("done")
|
_stage("done")
|
||||||
|
|||||||
+25
-22
@@ -13,6 +13,17 @@ import aiohttp
|
|||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
extract_video_url,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy_api.latest import InputImpl
|
from comfy_api.latest import InputImpl
|
||||||
@@ -154,11 +165,13 @@ class KVideoFirstLast:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交
|
# 1. 提交
|
||||||
|
check_interrupt()
|
||||||
_stage("submitting")
|
_stage("submitting")
|
||||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url, json=body, headers=headers, prefix="K26 提交: "
|
session, "POST", create_url, json=body, headers=headers, prefix="K26 提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
create_resp = json.loads(text)
|
create_resp = json.loads(text)
|
||||||
|
|
||||||
@@ -177,6 +190,7 @@ class KVideoFirstLast:
|
|||||||
video_url = None
|
video_url = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -189,40 +203,28 @@ class KVideoFirstLast:
|
|||||||
sr = json.loads(text)
|
sr = json.loads(text)
|
||||||
|
|
||||||
data = sr.get("data", sr)
|
data = sr.get("data", sr)
|
||||||
status = (data.get("status") or sr.get("status") or "").lower()
|
status = extract_status(sr)
|
||||||
|
|
||||||
pct_raw = data.get("progress", 0)
|
pct = extract_progress(sr)
|
||||||
try:
|
|
||||||
pct = int(str(pct_raw).rstrip("%").strip())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pct = 0
|
|
||||||
print(f"[K26 图生视频] 生成中 {pct}%")
|
print(f"[K26 图生视频] 生成中 {pct}%")
|
||||||
_progress(pct)
|
_progress(pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
# 提取视频 URL
|
# 提取视频 URL
|
||||||
video_url = (
|
video_url = extract_video_url(sr)
|
||||||
data.get("video_url")
|
|
||||||
or data.get("result_url")
|
|
||||||
or data.get("url")
|
|
||||||
or (data.get("result", {}) or {}).get("url")
|
|
||||||
or sr.get("video_url")
|
|
||||||
or sr.get("url")
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
if status in ("failed", "fail"):
|
if is_failure_status(status, sr):
|
||||||
err_info = data.get("error") or sr.get("error") or {}
|
err_msg = extract_error_message(sr)
|
||||||
err_msg = (err_info.get("message", "未知错误")
|
|
||||||
if isinstance(err_info, dict) else str(err_info))
|
|
||||||
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
interval = min(interval * 1.5, _POLL_MAX)
|
interval = min(interval * 1.5, _POLL_MAX)
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||||
|
|
||||||
# 3. 下载
|
# 3. 下载
|
||||||
|
check_interrupt()
|
||||||
_stage("downloading")
|
_stage("downloading")
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -230,6 +232,7 @@ class KVideoFirstLast:
|
|||||||
os.close(tmp_fd)
|
os.close(tmp_fd)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
_stage("done")
|
_stage("done")
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import aiohttp
|
|||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
from ..utils.http_error import async_request_with_retry
|
from ..utils.http_error import async_request_with_retry
|
||||||
|
from ..utils.video_task import (
|
||||||
|
check_interrupt,
|
||||||
|
extract_error_message,
|
||||||
|
extract_progress,
|
||||||
|
extract_status,
|
||||||
|
extract_video_url,
|
||||||
|
interruptible_sleep,
|
||||||
|
is_failure_status,
|
||||||
|
is_success_status,
|
||||||
|
run_with_interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy_api.latest import InputImpl
|
from comfy_api.latest import InputImpl
|
||||||
@@ -150,11 +161,13 @@ class KVideoImage2Video:
|
|||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
|
||||||
# 1. 提交
|
# 1. 提交
|
||||||
|
check_interrupt()
|
||||||
_stage("submitting")
|
_stage("submitting")
|
||||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||||
resp = await async_request_with_retry(
|
resp = await run_with_interrupt(async_request_with_retry(
|
||||||
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
|
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
|
||||||
)
|
))
|
||||||
|
check_interrupt()
|
||||||
sr = await resp.json()
|
sr = await resp.json()
|
||||||
|
|
||||||
task_id = sr.get("task_id") or sr.get("id")
|
task_id = sr.get("task_id") or sr.get("id")
|
||||||
@@ -169,8 +182,9 @@ class KVideoImage2Video:
|
|||||||
video_url = None
|
video_url = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(interval)
|
await interruptible_sleep(interval)
|
||||||
|
|
||||||
|
check_interrupt()
|
||||||
async with session.get(status_url, headers=headers) as resp:
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
err_text = await resp.text()
|
err_text = await resp.text()
|
||||||
@@ -178,40 +192,27 @@ class KVideoImage2Video:
|
|||||||
sr = await resp.json()
|
sr = await resp.json()
|
||||||
|
|
||||||
data = sr.get("data", {}) or {}
|
data = sr.get("data", {}) or {}
|
||||||
status = (sr.get("status") or data.get("status") or "").lower()
|
status = extract_status(sr)
|
||||||
|
|
||||||
pct_raw = str(data.get("progress", 0)).strip().rstrip('%')
|
pct = extract_progress(sr)
|
||||||
try:
|
|
||||||
pct = max(0, min(100, int(float(pct_raw))))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pct = 0
|
|
||||||
print(f"[K26 图生视频] 生成中 {pct}%")
|
print(f"[K26 图生视频] 生成中 {pct}%")
|
||||||
_progress(pct)
|
_progress(pct)
|
||||||
|
|
||||||
if status in ("success", "completed", "done", "finished", "succeed"):
|
if is_success_status(status):
|
||||||
# 提取视频 URL
|
# 提取视频 URL
|
||||||
video_url = (
|
video_url = extract_video_url(sr)
|
||||||
data.get("video_url")
|
|
||||||
or data.get("result_url")
|
|
||||||
or data.get("url")
|
|
||||||
or (data.get("result", {}) or {}).get("url")
|
|
||||||
or sr.get("video_url")
|
|
||||||
or sr.get("url")
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
if status in ("failed", "fail"):
|
if is_failure_status(status, sr):
|
||||||
err_info = data.get("error") or sr.get("error") or {}
|
err_msg = extract_error_message(sr)
|
||||||
err_msg = (err_info.get("message", "未知错误")
|
|
||||||
if isinstance(err_info, dict) else str(err_info))
|
|
||||||
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
interval = min(interval * 1.5, _POLL_MAX)
|
interval = min(interval * 1.5, _POLL_MAX)
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||||
|
|
||||||
# 3. 下载
|
# 3. 下载
|
||||||
|
check_interrupt()
|
||||||
_stage("downloading")
|
_stage("downloading")
|
||||||
async with session.get(video_url, allow_redirects=True) as resp:
|
async with session.get(video_url, allow_redirects=True) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
@@ -219,6 +220,7 @@ class KVideoImage2Video:
|
|||||||
os.close(tmp_fd)
|
os.close(tmp_fd)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192):
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
check_interrupt()
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
_stage("done")
|
_stage("done")
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ from .batch_images_o1key import BatchImagesO1key
|
|||||||
from .seedance_video import Seedance, SeedanceMultiModal
|
from .seedance_video import Seedance, SeedanceMultiModal
|
||||||
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
|
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
|
||||||
from .doubao_image import DoubaoImage
|
from .doubao_image import DoubaoImage
|
||||||
from .gpt_image import O1keyGPTImage
|
from .gpt_image import O1keyGPTImage, O1keyGPTImageBatch
|
||||||
from .grok_image import O1keyGrokImage
|
from .grok_image import O1keyGrokImage
|
||||||
from .K_video_firstlast import KVideoFirstLast
|
from .K_video_firstlast import KVideoFirstLast
|
||||||
from .K_video_image2video import KVideoImage2Video
|
from .K_video_image2video import KVideoImage2Video
|
||||||
@@ -31,5 +31,6 @@ from .save_image_format import SaveImageFormat
|
|||||||
from .save_psd import O1keySavePSD
|
from .save_psd import O1keySavePSD
|
||||||
from .remove_bg import O1keyRemoveBackground
|
from .remove_bg import O1keyRemoveBackground
|
||||||
from .color_remove_bg import O1keyColorRemoveBG
|
from .color_remove_bg import O1keyColorRemoveBG
|
||||||
|
from .grid_splitter import O1keyGridSplitter
|
||||||
|
|
||||||
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG']
|
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG', 'O1keyGridSplitter']
|
||||||
|
|||||||
+23
-19
@@ -20,7 +20,7 @@ from PIL import Image
|
|||||||
import torch
|
import torch
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_image_to_base64, encode_image_to_base64_limited
|
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_images_for_request_body_limit
|
||||||
from ..utils.file_utils import (
|
from ..utils.file_utils import (
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
load_images_from_folder,
|
load_images_from_folder,
|
||||||
@@ -95,22 +95,6 @@ def _build_request_body(
|
|||||||
images: Optional[List[Image.Image]] = None,
|
images: Optional[List[Image.Image]] = None,
|
||||||
enable_grounding: bool = False,
|
enable_grounding: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
content_parts = [{"type": "text", "text": prompt}]
|
|
||||||
|
|
||||||
if images:
|
|
||||||
for img in images:
|
|
||||||
b64 = encode_image_to_base64_limited(img, format="PNG")
|
|
||||||
content_parts.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": f"data:image/png;base64,{b64}"}
|
|
||||||
})
|
|
||||||
|
|
||||||
body = {
|
|
||||||
"model": model,
|
|
||||||
"stream": True,
|
|
||||||
"messages": [{"role": "user", "content": content_parts}],
|
|
||||||
}
|
|
||||||
|
|
||||||
google_config = {
|
google_config = {
|
||||||
"image_config": {
|
"image_config": {
|
||||||
"image_size": resolution,
|
"image_size": resolution,
|
||||||
@@ -118,13 +102,33 @@ def _build_request_body(
|
|||||||
}
|
}
|
||||||
if aspect_ratio and aspect_ratio != "智能":
|
if aspect_ratio and aspect_ratio != "智能":
|
||||||
google_config["image_config"]["aspect_ratio"] = aspect_ratio
|
google_config["image_config"]["aspect_ratio"] = aspect_ratio
|
||||||
body["extra_body"] = {"google": google_config}
|
|
||||||
|
def _make_body(encoded_images: Optional[List[tuple]] = None) -> dict:
|
||||||
|
content_parts = [{"type": "text", "text": prompt}]
|
||||||
|
if encoded_images:
|
||||||
|
for mime_type, b64 in encoded_images:
|
||||||
|
content_parts.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:{mime_type};base64,{b64}"}
|
||||||
|
})
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"stream": True,
|
||||||
|
"messages": [{"role": "user", "content": content_parts}],
|
||||||
|
"extra_body": {"google": google_config},
|
||||||
|
}
|
||||||
|
|
||||||
if enable_grounding:
|
if enable_grounding:
|
||||||
body["extra_body"]["google_search"] = True
|
body["extra_body"]["google_search"] = True
|
||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
encoded_images = None
|
||||||
|
if images:
|
||||||
|
encoded_images = encode_images_for_request_body_limit(images, _make_body)
|
||||||
|
|
||||||
|
return _make_body(encoded_images)
|
||||||
|
|
||||||
|
|
||||||
async def _generate_single_openai(
|
async def _generate_single_openai(
|
||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
|
|||||||
+478
-1
@@ -3,10 +3,23 @@ o1key GPT Image 节点
|
|||||||
支持 gpt-image-1 / gpt-image-1.5 模型的文生图、图生图、图像编辑(带蒙版)
|
支持 gpt-image-1 / gpt-image-1.5 模型的文生图、图生图、图像编辑(带蒙版)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from ..clients.gpt_image_client import GptImageClient
|
from ..clients.gpt_image_client import GptImageClient
|
||||||
from ..utils.image_utils import parse_batch_prompts
|
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
|
||||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||||
|
from ..utils.file_utils import (
|
||||||
|
ImageInfo,
|
||||||
|
generate_timestamp_filename,
|
||||||
|
load_images_from_folder,
|
||||||
|
pair_images_by_name,
|
||||||
|
pair_images_cartesian,
|
||||||
|
save_image,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
||||||
@@ -16,6 +29,18 @@ except ImportError:
|
|||||||
processing_interrupted = lambda: False
|
processing_interrupted = lambda: False
|
||||||
InterruptProcessingException = RuntimeError
|
InterruptProcessingException = RuntimeError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from comfy.utils import ProgressBar
|
||||||
|
_PROGRESS_BAR_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_PROGRESS_BAR_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import folder_paths
|
||||||
|
_FOLDER_PATHS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_FOLDER_PATHS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
class O1keyGPTImage:
|
class O1keyGPTImage:
|
||||||
"""
|
"""
|
||||||
@@ -270,3 +295,455 @@ class O1keyGPTImage:
|
|||||||
print(f"[o1key GPT Image] {balance_info}")
|
print(f"[o1key GPT Image] {balance_info}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class O1keyGPTImageBatch:
|
||||||
|
"""
|
||||||
|
o1key GPT Image 批量节点
|
||||||
|
|
||||||
|
复用 BatchNanoBananaPro 的批量思路:
|
||||||
|
- 从文件夹批量加载图片
|
||||||
|
- 按文件名同名 / 1*N / 不配对 三种模式创建任务
|
||||||
|
- 可追加节点手动输入参考图
|
||||||
|
- prompt 支持用独占一行 --- 展开为多提示词任务
|
||||||
|
- 每个任务调用 GPT Image 客户端并保存到磁盘
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
|
||||||
|
IMAGE_FORMATS = ["原始", "JPEG", "PNG", "WebP"]
|
||||||
|
MODEL_OPTIONS = ["gpt-image-2-按量", "gpt-image-2-次卡"]
|
||||||
|
QUALITY_OPTIONS = ["高", "中", "低", "自动"]
|
||||||
|
RESOLUTION_OPTIONS = [
|
||||||
|
"智能",
|
||||||
|
"1024x1024(1K 正方形 1:1)",
|
||||||
|
"1536x1024(1K 横版 3:2)",
|
||||||
|
"1024x1536(1K 竖版 2:3)",
|
||||||
|
"1360x1024(1K 横版 4:3)",
|
||||||
|
"1024x1360(1K 竖版 3:4)",
|
||||||
|
"1824x1024(1K 横版 16:9)",
|
||||||
|
"1024x1824(1K 竖版 9:16)",
|
||||||
|
"2048x2048(2K 正方形 1:1)",
|
||||||
|
"3072x2048(2K 横版 3:2)",
|
||||||
|
"2048x3072(2K 竖版 2:3)",
|
||||||
|
"2736x2048(2K 横版 4:3)",
|
||||||
|
"2048x2736(2K 竖版 3:4)",
|
||||||
|
"3648x2048(2K 横版 16:9)",
|
||||||
|
"2048x3648(2K 竖版 9:16)",
|
||||||
|
"2880x2880(4K 正方形 1:1)",
|
||||||
|
"3504x2336(4K 横版 3:2)",
|
||||||
|
"2336x3504(4K 竖版 2:3)",
|
||||||
|
"3264x2448(4K 横版 4:3)",
|
||||||
|
"2448x3264(4K 竖版 3:4)",
|
||||||
|
"3840x2160(4K 横版 16:9)",
|
||||||
|
"2160x3840(4K 竖版 9:16)",
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
optional_inputs = {}
|
||||||
|
for image_index in range(1, 10):
|
||||||
|
optional_inputs[f"参考图{image_index}"] = ("IMAGE", {
|
||||||
|
"tooltip": "追加到每个批量任务末尾的固定参考图。",
|
||||||
|
})
|
||||||
|
|
||||||
|
optional_inputs["遮罩"] = ("MASK", {
|
||||||
|
"tooltip": "可选蒙版,会应用到每个任务的第一张参考图;请确保尺寸一致。",
|
||||||
|
})
|
||||||
|
optional_inputs["图片配对模式"] = (cls.PAIRING_MODES, {
|
||||||
|
"default": "不配对",
|
||||||
|
"tooltip": "文件夹图片的组合方式;手动参考图只追加,不参与配对。",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"prompt": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": "提示词;可用独占一行的 --- 分隔多条批量提示词。",
|
||||||
|
}),
|
||||||
|
"模型": (cls.MODEL_OPTIONS, {
|
||||||
|
"default": "gpt-image-2-次卡",
|
||||||
|
}),
|
||||||
|
"网络": (NETWORK_ROUTE_OPTIONS, {
|
||||||
|
"default": "全球加速",
|
||||||
|
}),
|
||||||
|
"分辨率": (cls.RESOLUTION_OPTIONS, {
|
||||||
|
"default": "智能",
|
||||||
|
}),
|
||||||
|
"生图数量": ("INT", {
|
||||||
|
"default": 1,
|
||||||
|
"min": 1,
|
||||||
|
"max": 8,
|
||||||
|
"step": 1,
|
||||||
|
"display": "number",
|
||||||
|
}),
|
||||||
|
"质量": (cls.QUALITY_OPTIONS, {
|
||||||
|
"default": "自动",
|
||||||
|
}),
|
||||||
|
"seed": ("INT", {
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 2**31 - 1,
|
||||||
|
"step": 1,
|
||||||
|
"display": "number",
|
||||||
|
"control_after_generate": True,
|
||||||
|
}),
|
||||||
|
"图片格式": (cls.IMAGE_FORMATS, {
|
||||||
|
"default": "原始",
|
||||||
|
}),
|
||||||
|
"文件夹1": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
}),
|
||||||
|
"文件夹2": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
}),
|
||||||
|
"文件夹3": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
}),
|
||||||
|
"文件夹4": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
}),
|
||||||
|
"文件夹5": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
}),
|
||||||
|
"保存路径": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
"tooltip": "为空时优先使用 ComfyUI 默认 output 目录。",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"optional": optional_inputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE",)
|
||||||
|
RETURN_NAMES = ("IMAGE",)
|
||||||
|
FUNCTION = "process_batch"
|
||||||
|
CATEGORY = "o1key/image"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
def _load_folders(self, folders: List[str]) -> List[List[ImageInfo]]:
|
||||||
|
image_lists = []
|
||||||
|
for folder_index, folder in enumerate(folders, 1):
|
||||||
|
if not folder or not folder.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
loaded_images = load_images_from_folder(folder)
|
||||||
|
if loaded_images:
|
||||||
|
image_lists.append(loaded_images)
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"[o1key GPT Image Batch] 文件夹{folder_index} 加载失败 - {error}")
|
||||||
|
return image_lists
|
||||||
|
|
||||||
|
def _create_pairs(
|
||||||
|
self,
|
||||||
|
image_lists: List[List[ImageInfo]],
|
||||||
|
pairing_mode: str,
|
||||||
|
manual_images: Optional[List[ImageInfo]] = None,
|
||||||
|
) -> List[Tuple[ImageInfo, ...]]:
|
||||||
|
if pairing_mode == "不配对":
|
||||||
|
if len(image_lists) > 1:
|
||||||
|
raise ValueError("「不配对」模式只支持单个文件夹,请清空其他文件夹路径")
|
||||||
|
|
||||||
|
if image_lists and manual_images:
|
||||||
|
return [
|
||||||
|
(folder_image,) + tuple(manual_images)
|
||||||
|
for folder_image in image_lists[0]
|
||||||
|
]
|
||||||
|
if image_lists:
|
||||||
|
return [(folder_image,) for folder_image in image_lists[0]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not image_lists:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(image_lists) == 1:
|
||||||
|
base_pairs = [(folder_image,) for folder_image in image_lists[0]]
|
||||||
|
elif pairing_mode == "按相同图片命名":
|
||||||
|
base_pairs = list(pair_images_by_name(*image_lists))
|
||||||
|
else:
|
||||||
|
base_pairs = list(pair_images_cartesian(*image_lists))
|
||||||
|
|
||||||
|
if manual_images:
|
||||||
|
manual_tuple = tuple(manual_images)
|
||||||
|
base_pairs = [pair + manual_tuple for pair in base_pairs]
|
||||||
|
|
||||||
|
return base_pairs
|
||||||
|
|
||||||
|
def _collect_manual_images(self, kwargs) -> List[ImageInfo]:
|
||||||
|
manual_images = []
|
||||||
|
for image_index in range(1, 10):
|
||||||
|
key = f"参考图{image_index}"
|
||||||
|
if key not in kwargs or kwargs[key] is None:
|
||||||
|
continue
|
||||||
|
for tensor_index, image in enumerate(tensor_to_pil(kwargs[key])):
|
||||||
|
manual_images.append(ImageInfo(
|
||||||
|
image=image,
|
||||||
|
filename=f"manual_{image_index}_{tensor_index}",
|
||||||
|
extension=".png",
|
||||||
|
source_path="",
|
||||||
|
))
|
||||||
|
return manual_images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pair_to_tensors(pair: Tuple[ImageInfo, ...]) -> List:
|
||||||
|
return [pil_to_tensor([image_info.image]) for image_info in pair]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_size(分辨率: str) -> str:
|
||||||
|
return "auto" if 分辨率 == "智能" else 分辨率.split("(")[0].strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_model(模型: str) -> str:
|
||||||
|
model_map = {
|
||||||
|
"gpt-image-2-次卡": "gpt-image-2-c",
|
||||||
|
"gpt-image-2-按量": "gpt-image-2",
|
||||||
|
}
|
||||||
|
return model_map.get(模型, 模型)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_quality(质量: str) -> str:
|
||||||
|
quality_map = {"高": "high", "中": "medium", "低": "low", "自动": "auto"}
|
||||||
|
return quality_map.get(质量, "auto")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_output_folder(保存路径: str) -> str:
|
||||||
|
output_folder = (保存路径 or "").strip()
|
||||||
|
if not output_folder and _FOLDER_PATHS_AVAILABLE:
|
||||||
|
output_folder = folder_paths.get_output_directory()
|
||||||
|
print(f"[o1key GPT Image Batch] 未设置保存路径,使用 ComfyUI 默认 output 目录: {output_folder}")
|
||||||
|
|
||||||
|
if not output_folder:
|
||||||
|
raise ValueError("未设置保存路径,且当前环境无法获取 ComfyUI 默认 output 目录")
|
||||||
|
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
test_path = os.path.join(output_folder, ".write_test")
|
||||||
|
with open(test_path, "w", encoding="utf-8") as test_file:
|
||||||
|
test_file.write("test")
|
||||||
|
os.remove(test_path)
|
||||||
|
return output_folder
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_images(
|
||||||
|
images: List[Image.Image],
|
||||||
|
output_folder: str,
|
||||||
|
image_format: str,
|
||||||
|
base_filename: Optional[str] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
format_ext_map = {"JPEG": ".jpg", "PNG": ".png", "WebP": ".webp"}
|
||||||
|
save_ext = format_ext_map.get(image_format, ".png")
|
||||||
|
saved_files = []
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
if base_filename:
|
||||||
|
counter = 0
|
||||||
|
while True:
|
||||||
|
suffix = "" if counter == 0 else f"+{counter}"
|
||||||
|
filename = f"{base_filename}{suffix}{save_ext}"
|
||||||
|
output_path = os.path.join(output_folder, filename)
|
||||||
|
if not os.path.exists(output_path):
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
else:
|
||||||
|
output_path = generate_timestamp_filename(
|
||||||
|
output_folder=output_folder,
|
||||||
|
extension=save_ext,
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_format == "JPEG":
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.save(output_path, quality=100)
|
||||||
|
elif image_format == "WebP":
|
||||||
|
image.save(output_path, lossless=True)
|
||||||
|
else:
|
||||||
|
save_image(image, output_path)
|
||||||
|
|
||||||
|
saved_files.append(output_path)
|
||||||
|
|
||||||
|
return saved_files
|
||||||
|
|
||||||
|
def process_batch(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
模型: str,
|
||||||
|
网络: str,
|
||||||
|
分辨率: str,
|
||||||
|
生图数量: int,
|
||||||
|
质量: str,
|
||||||
|
seed: int,
|
||||||
|
图片格式: str,
|
||||||
|
文件夹1: str,
|
||||||
|
文件夹2: str,
|
||||||
|
文件夹3: str,
|
||||||
|
文件夹4: str,
|
||||||
|
文件夹5: str,
|
||||||
|
保存路径: str = "",
|
||||||
|
图片配对模式: str = "不配对",
|
||||||
|
遮罩=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
start_time = time.time()
|
||||||
|
client = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not prompt or not prompt.strip():
|
||||||
|
raise ValueError("提示词不能为空")
|
||||||
|
|
||||||
|
folders = [文件夹1, 文件夹2, 文件夹3, 文件夹4, 文件夹5]
|
||||||
|
if not any(folder and folder.strip() for folder in folders):
|
||||||
|
raise ValueError("请至少填写一个文件夹路径,该节点专为批量文件夹处理设计")
|
||||||
|
|
||||||
|
image_lists = self._load_folders(folders)
|
||||||
|
total_folder_images = sum(len(image_list) for image_list in image_lists)
|
||||||
|
if total_folder_images == 0:
|
||||||
|
raise ValueError("文件夹中未找到任何图片,请检查文件夹路径是否正确")
|
||||||
|
|
||||||
|
manual_images = self._collect_manual_images(kwargs)
|
||||||
|
pairs = self._create_pairs(
|
||||||
|
image_lists=image_lists,
|
||||||
|
pairing_mode=图片配对模式,
|
||||||
|
manual_images=manual_images if manual_images else None,
|
||||||
|
)
|
||||||
|
if not pairs:
|
||||||
|
raise ValueError("配对结果为空,请检查输入")
|
||||||
|
|
||||||
|
batch_prompts = parse_batch_prompts(prompt)
|
||||||
|
prompts_per_task = None
|
||||||
|
if batch_prompts:
|
||||||
|
expanded_pairs = []
|
||||||
|
expanded_prompts = []
|
||||||
|
for pair in pairs:
|
||||||
|
for batch_prompt in batch_prompts:
|
||||||
|
expanded_pairs.append(pair)
|
||||||
|
expanded_prompts.append(batch_prompt)
|
||||||
|
pairs = expanded_pairs
|
||||||
|
prompts_per_task = expanded_prompts
|
||||||
|
|
||||||
|
total_tasks = len(pairs)
|
||||||
|
if batch_prompts:
|
||||||
|
print(
|
||||||
|
f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} × "
|
||||||
|
f"{len(batch_prompts)} 个提示词 | 共 {total_tasks} 任务"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} | 共 {total_tasks} 任务")
|
||||||
|
|
||||||
|
output_folder = self._ensure_output_folder(保存路径)
|
||||||
|
size = self._resolve_size(分辨率)
|
||||||
|
model = self._resolve_model(模型)
|
||||||
|
quality = self._resolve_quality(质量)
|
||||||
|
|
||||||
|
client = GptImageClient()
|
||||||
|
client.base_url = get_base_url_by_route(网络)
|
||||||
|
|
||||||
|
progress_bar = ProgressBar(total_tasks) if _PROGRESS_BAR_AVAILABLE else None
|
||||||
|
results = []
|
||||||
|
all_saved_files = []
|
||||||
|
|
||||||
|
for task_index, pair in enumerate(pairs, 1):
|
||||||
|
if _INTERRUPT_AVAILABLE and processing_interrupted():
|
||||||
|
print("[o1key GPT Image Batch] 用户取消,已中断批量生成")
|
||||||
|
raise InterruptProcessingException()
|
||||||
|
|
||||||
|
task_prompt = prompts_per_task[task_index - 1] if prompts_per_task else prompt
|
||||||
|
base_filename = pair[0].filename if pair else None
|
||||||
|
result = {
|
||||||
|
"task_index": task_index,
|
||||||
|
"success": False,
|
||||||
|
"generated_count": 0,
|
||||||
|
"saved_files": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pil_images = client.run_sync(
|
||||||
|
prompt=task_prompt,
|
||||||
|
model=model,
|
||||||
|
quality=quality,
|
||||||
|
size=size,
|
||||||
|
n=生图数量,
|
||||||
|
seed=seed,
|
||||||
|
image_tensor=self._pair_to_tensors(pair),
|
||||||
|
mask_tensor=遮罩,
|
||||||
|
)
|
||||||
|
saved_files = self._save_images(
|
||||||
|
images=pil_images,
|
||||||
|
output_folder=output_folder,
|
||||||
|
image_format=图片格式,
|
||||||
|
base_filename=base_filename,
|
||||||
|
)
|
||||||
|
result["success"] = bool(pil_images)
|
||||||
|
result["generated_count"] = len(pil_images)
|
||||||
|
result["saved_files"] = saved_files
|
||||||
|
all_saved_files.extend(saved_files)
|
||||||
|
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ✓ {base_filename or 'task'}")
|
||||||
|
except InterruptProcessingException:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
error_msg = str(error).split("\n")[0]
|
||||||
|
result["error"] = error_msg
|
||||||
|
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ❌ {base_filename or 'task'} → {error_msg}")
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
if progress_bar is not None:
|
||||||
|
progress_bar.update(1)
|
||||||
|
|
||||||
|
success_count = sum(1 for result in results if result.get("success", False))
|
||||||
|
total_generated = sum(result.get("generated_count", 0) for result in results)
|
||||||
|
if success_count == 0:
|
||||||
|
raise RuntimeError("所有批量任务均生成失败,无可用图像输出")
|
||||||
|
|
||||||
|
output_images = []
|
||||||
|
for file_path in all_saved_files[-10:]:
|
||||||
|
try:
|
||||||
|
loaded_image = Image.open(file_path)
|
||||||
|
loaded_image.load()
|
||||||
|
output_images.append(loaded_image)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"[o1key GPT Image Batch] 无法加载输出图片 {file_path} - {error}")
|
||||||
|
|
||||||
|
if not output_images:
|
||||||
|
output_images = [Image.new("RGBA", (512, 512), (128, 128, 128, 255))]
|
||||||
|
|
||||||
|
output_tensor = GptImageClient._pil_list_to_tensor(output_images)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print("=" * 60)
|
||||||
|
print(
|
||||||
|
f"[o1key GPT Image Batch] 完成!耗时 {elapsed:.1f}s | "
|
||||||
|
f"成功 {success_count}/{total_tasks} | 生成 {total_generated} 张"
|
||||||
|
)
|
||||||
|
print(f"[o1key GPT Image Batch] 保存路径: {output_folder}")
|
||||||
|
if all_saved_files:
|
||||||
|
print(f"[o1key GPT Image Batch] 最新保存文件: {all_saved_files[-1]}")
|
||||||
|
|
||||||
|
failed_results = [result for result in results if not result.get("success", False)]
|
||||||
|
if failed_results:
|
||||||
|
print(f"[o1key GPT Image Batch] 失败任务: {len(failed_results)} 个")
|
||||||
|
for failed_result in failed_results[:3]:
|
||||||
|
print(
|
||||||
|
f" - #{failed_result.get('task_index')}: "
|
||||||
|
f"{failed_result.get('error', '未知错误')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (output_tensor,)
|
||||||
|
|
||||||
|
except ValueError as error:
|
||||||
|
if str(error) == "未授权!":
|
||||||
|
print("[o1key GPT Image Batch] 请联系作者授权后方可使用!")
|
||||||
|
raise ValueError("未授权!") from None
|
||||||
|
raise ValueError(str(error)) from None
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise RuntimeError(str(error)) from None
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
balance_data = client.query_balance_sync()
|
||||||
|
balance_info = client.format_balance_info(balance_data)
|
||||||
|
print(f"[o1key GPT Image Batch] {balance_info}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""
|
||||||
|
Merged grid image splitter.
|
||||||
|
|
||||||
|
This node is designed for AI-generated contact sheets such as 3x3 or 2x3
|
||||||
|
grids. Auto mode scores common layouts by looking for strong seams or flat
|
||||||
|
separator bands near the expected grid lines, then crops each cell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Sequence, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from ..utils.image_utils import pil_to_tensor, tensor_to_pil
|
||||||
|
|
||||||
|
|
||||||
|
_AUTO_LAYOUTS: Sequence[Tuple[int, int]] = (
|
||||||
|
(3, 3),
|
||||||
|
(2, 3),
|
||||||
|
(3, 2),
|
||||||
|
(2, 2),
|
||||||
|
(1, 2),
|
||||||
|
(2, 1),
|
||||||
|
(1, 3),
|
||||||
|
(3, 1),
|
||||||
|
(4, 4),
|
||||||
|
(3, 4),
|
||||||
|
(4, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
_LAYOUTS = [
|
||||||
|
"auto",
|
||||||
|
"1x2",
|
||||||
|
"2x1",
|
||||||
|
"1x3",
|
||||||
|
"3x1",
|
||||||
|
"2x2",
|
||||||
|
"2x3",
|
||||||
|
"3x2",
|
||||||
|
"3x3",
|
||||||
|
"3x4",
|
||||||
|
"4x3",
|
||||||
|
"4x4",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _AxisCut:
|
||||||
|
seam: int
|
||||||
|
span_start: int
|
||||||
|
span_end: int
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _AxisPlan:
|
||||||
|
intervals: List[Tuple[int, int]]
|
||||||
|
cuts: List[_AxisCut]
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_array(image: Image.Image) -> np.ndarray:
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
return np.asarray(image).astype(np.float32) / 255.0
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_texture(arr: np.ndarray, axis: str) -> np.ndarray:
|
||||||
|
if axis == "x":
|
||||||
|
profile = arr.std(axis=(0, 2))
|
||||||
|
else:
|
||||||
|
profile = arr.std(axis=(1, 2))
|
||||||
|
high = np.percentile(profile, 95) + 1e-6
|
||||||
|
return np.clip(profile / high, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_edge(arr: np.ndarray, axis: str) -> np.ndarray:
|
||||||
|
if axis == "x":
|
||||||
|
diff = np.abs(np.diff(arr, axis=1)).mean(axis=(0, 2))
|
||||||
|
length = arr.shape[1]
|
||||||
|
else:
|
||||||
|
diff = np.abs(np.diff(arr, axis=0)).mean(axis=(1, 2))
|
||||||
|
length = arr.shape[0]
|
||||||
|
|
||||||
|
padded = np.zeros(length, dtype=np.float32)
|
||||||
|
if diff.size:
|
||||||
|
padded[1:] = diff
|
||||||
|
high = np.percentile(padded, 95) + 1e-6
|
||||||
|
return np.clip(padded / high, 0.0, 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth(profile: np.ndarray, radius: int = 2) -> np.ndarray:
|
||||||
|
if radius <= 0 or profile.size < radius * 2 + 1:
|
||||||
|
return profile
|
||||||
|
kernel = np.ones(radius * 2 + 1, dtype=np.float32) / float(radius * 2 + 1)
|
||||||
|
return np.convolve(profile, kernel, mode="same")
|
||||||
|
|
||||||
|
|
||||||
|
def _separator_span(
|
||||||
|
texture: np.ndarray,
|
||||||
|
seam: int,
|
||||||
|
search_px: int,
|
||||||
|
min_separator_px: int,
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
length = texture.size
|
||||||
|
if length <= 1:
|
||||||
|
return 0, length
|
||||||
|
|
||||||
|
limit = max(1, min(search_px, length // 8))
|
||||||
|
threshold = max(0.08, min(0.28, float(np.percentile(texture, 12)) * 1.8))
|
||||||
|
|
||||||
|
left = seam
|
||||||
|
while left > 0 and seam - left < limit and texture[left - 1] <= threshold:
|
||||||
|
left -= 1
|
||||||
|
|
||||||
|
right = seam
|
||||||
|
while right < length and right - seam < limit and texture[right] <= threshold:
|
||||||
|
right += 1
|
||||||
|
|
||||||
|
if right - left >= max(1, min_separator_px):
|
||||||
|
return left, right
|
||||||
|
|
||||||
|
return seam, seam
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_trim(texture: np.ndarray, search_px: int, min_cell: int) -> Tuple[int, int]:
|
||||||
|
length = texture.size
|
||||||
|
if length <= 2:
|
||||||
|
return 0, length
|
||||||
|
|
||||||
|
max_trim = max(0, min(search_px * 2, min_cell // 3, length // 6))
|
||||||
|
if max_trim <= 0:
|
||||||
|
return 0, length
|
||||||
|
|
||||||
|
threshold = max(0.08, min(0.24, float(np.percentile(texture, 12)) * 1.6))
|
||||||
|
|
||||||
|
start = 0
|
||||||
|
while start < max_trim and texture[start] <= threshold:
|
||||||
|
start += 1
|
||||||
|
|
||||||
|
end = length
|
||||||
|
while length - end < max_trim and end > start + min_cell and texture[end - 1] <= threshold:
|
||||||
|
end -= 1
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_plan(
|
||||||
|
arr: np.ndarray,
|
||||||
|
cells: int,
|
||||||
|
axis: str,
|
||||||
|
search_px: int,
|
||||||
|
crop_separators: bool,
|
||||||
|
trim_outer: bool,
|
||||||
|
min_separator_px: int,
|
||||||
|
) -> _AxisPlan:
|
||||||
|
length = arr.shape[1] if axis == "x" else arr.shape[0]
|
||||||
|
if cells <= 1:
|
||||||
|
return _AxisPlan(intervals=[(0, length)], cuts=[], score=0.0)
|
||||||
|
|
||||||
|
raw_texture = _axis_texture(arr, axis)
|
||||||
|
raw_edge = _axis_edge(arr, axis)
|
||||||
|
texture = _smooth(raw_texture, radius=2)
|
||||||
|
edge = _smooth(raw_edge, radius=1)
|
||||||
|
evidence = np.maximum(edge, (1.0 - texture) * 0.75)
|
||||||
|
exact_evidence = np.maximum(raw_edge, (1.0 - raw_texture) * 0.75)
|
||||||
|
|
||||||
|
cuts: List[_AxisCut] = []
|
||||||
|
scores: List[float] = []
|
||||||
|
for idx in range(1, cells):
|
||||||
|
expected = round(length * idx / cells)
|
||||||
|
start = max(1, expected - search_px)
|
||||||
|
end = min(length - 1, expected + search_px)
|
||||||
|
if start >= end:
|
||||||
|
seam = expected
|
||||||
|
score = 0.0
|
||||||
|
else:
|
||||||
|
window = evidence[start:end + 1]
|
||||||
|
offset = int(window.argmax())
|
||||||
|
coarse = start + offset
|
||||||
|
fine_start = max(start, coarse - 2)
|
||||||
|
fine_end = min(end, coarse + 2)
|
||||||
|
fine_window = exact_evidence[fine_start:fine_end + 1]
|
||||||
|
seam = fine_start + int(fine_window.argmax())
|
||||||
|
score = float(window[offset])
|
||||||
|
|
||||||
|
span_start, span_end = _separator_span(
|
||||||
|
raw_texture,
|
||||||
|
seam,
|
||||||
|
search_px=search_px,
|
||||||
|
min_separator_px=min_separator_px,
|
||||||
|
)
|
||||||
|
cuts.append(_AxisCut(seam=seam, span_start=span_start, span_end=span_end, score=score))
|
||||||
|
scores.append(score)
|
||||||
|
|
||||||
|
min_cell = max(1, length // cells)
|
||||||
|
outer_start, outer_end = _edge_trim(raw_texture, search_px, min_cell) if trim_outer else (0, length)
|
||||||
|
|
||||||
|
intervals: List[Tuple[int, int]] = []
|
||||||
|
cursor = outer_start
|
||||||
|
for cut in cuts:
|
||||||
|
split_start = cut.span_start if crop_separators else cut.seam
|
||||||
|
split_end = cut.span_end if crop_separators else cut.seam
|
||||||
|
intervals.append((cursor, split_start))
|
||||||
|
cursor = split_end
|
||||||
|
intervals.append((cursor, outer_end))
|
||||||
|
|
||||||
|
cleaned: List[Tuple[int, int]] = []
|
||||||
|
for start, end in intervals:
|
||||||
|
start = max(0, min(length - 1, int(start)))
|
||||||
|
end = max(start + 1, min(length, int(end)))
|
||||||
|
cleaned.append((start, end))
|
||||||
|
|
||||||
|
return _AxisPlan(
|
||||||
|
intervals=cleaned,
|
||||||
|
cuts=cuts,
|
||||||
|
score=float(np.mean(scores)) if scores else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_layout(layout: str, custom_rows: int, custom_cols: int) -> Tuple[int, int]:
|
||||||
|
if layout == "custom":
|
||||||
|
return max(1, int(custom_rows)), max(1, int(custom_cols))
|
||||||
|
rows_text, cols_text = layout.split("x", 1)
|
||||||
|
return int(rows_text), int(cols_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_layout(width: int, height: int) -> Tuple[int, int]:
|
||||||
|
aspect = width / max(1, height)
|
||||||
|
if 0.82 <= aspect <= 1.22:
|
||||||
|
return 3, 3
|
||||||
|
if aspect > 1.22:
|
||||||
|
return 2, 3
|
||||||
|
return 3, 2
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_auto_layout(
|
||||||
|
arr: np.ndarray,
|
||||||
|
search_px: int,
|
||||||
|
crop_separators: bool,
|
||||||
|
trim_outer: bool,
|
||||||
|
min_separator_px: int,
|
||||||
|
) -> Tuple[int, int, _AxisPlan, _AxisPlan, float, bool]:
|
||||||
|
height, width = arr.shape[:2]
|
||||||
|
best = None
|
||||||
|
|
||||||
|
for rows, cols in _AUTO_LAYOUTS:
|
||||||
|
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
score = (x_plan.score + y_plan.score) / 2.0
|
||||||
|
|
||||||
|
# Prefer common 3x3 / 2x3 / 3x2 layouts when the image gives weak signals.
|
||||||
|
if (rows, cols) in ((3, 3), (2, 3), (3, 2)):
|
||||||
|
score += 0.025
|
||||||
|
|
||||||
|
if best is None or score > best[0]:
|
||||||
|
best = (score, rows, cols, x_plan, y_plan)
|
||||||
|
|
||||||
|
assert best is not None
|
||||||
|
score, rows, cols, x_plan, y_plan = best
|
||||||
|
confident = score >= 0.22
|
||||||
|
|
||||||
|
if confident:
|
||||||
|
return rows, cols, x_plan, y_plan, score, True
|
||||||
|
|
||||||
|
rows, cols = _fallback_layout(width, height)
|
||||||
|
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
return rows, cols, x_plan, y_plan, score, False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sizes(crops: List[Image.Image]) -> List[Image.Image]:
|
||||||
|
min_w = min(crop.width for crop in crops)
|
||||||
|
min_h = min(crop.height for crop in crops)
|
||||||
|
normalized = []
|
||||||
|
for crop in crops:
|
||||||
|
left = max(0, (crop.width - min_w) // 2)
|
||||||
|
top = max(0, (crop.height - min_h) // 2)
|
||||||
|
normalized.append(crop.crop((left, top, left + min_w, top + min_h)))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _split_one(
|
||||||
|
image: Image.Image,
|
||||||
|
layout: str,
|
||||||
|
custom_rows: int,
|
||||||
|
custom_cols: int,
|
||||||
|
search_px: int,
|
||||||
|
crop_separators: bool,
|
||||||
|
trim_outer: bool,
|
||||||
|
min_separator_px: int,
|
||||||
|
) -> Tuple[List[Image.Image], str]:
|
||||||
|
arr = _to_float_array(image)
|
||||||
|
|
||||||
|
if layout == "auto":
|
||||||
|
rows, cols, x_plan, y_plan, confidence, confident = _choose_auto_layout(
|
||||||
|
arr,
|
||||||
|
search_px=search_px,
|
||||||
|
crop_separators=crop_separators,
|
||||||
|
trim_outer=trim_outer,
|
||||||
|
min_separator_px=min_separator_px,
|
||||||
|
)
|
||||||
|
mode_note = "auto" if confident else "auto-low-confidence-fallback"
|
||||||
|
else:
|
||||||
|
rows, cols = _parse_layout(layout, custom_rows, custom_cols)
|
||||||
|
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
|
||||||
|
confidence = (x_plan.score + y_plan.score) / 2.0
|
||||||
|
mode_note = "manual"
|
||||||
|
|
||||||
|
crops: List[Image.Image] = []
|
||||||
|
for y0, y1 in y_plan.intervals:
|
||||||
|
for x0, x1 in x_plan.intervals:
|
||||||
|
crops.append(image.crop((x0, y0, x1, y1)))
|
||||||
|
|
||||||
|
crops = _normalize_sizes(crops)
|
||||||
|
info = (
|
||||||
|
f"{mode_note}: {rows}x{cols}, cells={len(crops)}, "
|
||||||
|
f"confidence={confidence:.3f}, "
|
||||||
|
f"x={x_plan.intervals}, y={y_plan.intervals}"
|
||||||
|
)
|
||||||
|
return crops, info
|
||||||
|
|
||||||
|
|
||||||
|
class O1keyGridSplitter:
|
||||||
|
"""Split AI-generated grid/contact-sheet images into individual cells."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"图像": ("IMAGE",),
|
||||||
|
"布局": (_LAYOUTS, {"default": "auto"}),
|
||||||
|
"自定义行数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
|
||||||
|
"自定义列数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
|
||||||
|
"搜索范围px": ("INT", {"default": 32, "min": 0, "max": 256, "step": 1}),
|
||||||
|
"裁掉分隔线": ("BOOLEAN", {"default": True}),
|
||||||
|
"裁掉外边距": ("BOOLEAN", {"default": True}),
|
||||||
|
"最小分隔线px": ("INT", {"default": 2, "min": 0, "max": 64, "step": 1}),
|
||||||
|
"最大输出张数": ("INT", {"default": 16, "min": 1, "max": 144, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "STRING")
|
||||||
|
RETURN_NAMES = ("切割图像", "检测信息")
|
||||||
|
FUNCTION = "split_grid"
|
||||||
|
CATEGORY = "o1key/image"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"智能切割 AI 生成的九宫格、六宫格等合并图。"
|
||||||
|
"自动模式会检测常见布局;没有明显分隔线时建议手动选择布局。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def split_grid(
|
||||||
|
self,
|
||||||
|
图像: torch.Tensor,
|
||||||
|
布局: str = "auto",
|
||||||
|
自定义行数: int = 3,
|
||||||
|
自定义列数: int = 3,
|
||||||
|
搜索范围px: int = 32,
|
||||||
|
裁掉分隔线: bool = True,
|
||||||
|
裁掉外边距: bool = True,
|
||||||
|
最小分隔线px: int = 2,
|
||||||
|
最大输出张数: int = 16,
|
||||||
|
):
|
||||||
|
source_images = tensor_to_pil(图像)
|
||||||
|
all_crops: List[Image.Image] = []
|
||||||
|
info_lines: List[str] = []
|
||||||
|
|
||||||
|
for batch_index, image in enumerate(source_images, start=1):
|
||||||
|
crops, info = _split_one(
|
||||||
|
image=image,
|
||||||
|
layout=布局,
|
||||||
|
custom_rows=自定义行数,
|
||||||
|
custom_cols=自定义列数,
|
||||||
|
search_px=搜索范围px,
|
||||||
|
crop_separators=裁掉分隔线,
|
||||||
|
trim_outer=裁掉外边距,
|
||||||
|
min_separator_px=最小分隔线px,
|
||||||
|
)
|
||||||
|
if len(crops) > 最大输出张数:
|
||||||
|
raise ValueError(
|
||||||
|
f"合并图切割:检测到 {len(crops)} 张,超过最大输出张数 {最大输出张数}。"
|
||||||
|
"请调大最大输出张数,或检查布局设置。"
|
||||||
|
)
|
||||||
|
all_crops.extend(crops)
|
||||||
|
info_lines.append(f"batch {batch_index}: {info}")
|
||||||
|
|
||||||
|
if not all_crops:
|
||||||
|
raise ValueError("合并图切割:没有生成任何切片。")
|
||||||
|
|
||||||
|
all_crops = _normalize_sizes(all_crops)
|
||||||
|
print("[o1key 合并图切割] " + " | ".join(info_lines))
|
||||||
|
return (pil_to_tensor(all_crops), "\n".join(info_lines))
|
||||||
+106
-27
@@ -22,7 +22,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from comfy_api.latest import io
|
from comfy_api.latest import io
|
||||||
|
|
||||||
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_image_to_base64, encode_image_to_base64_limited
|
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_images_for_image_size_limit
|
||||||
from ..utils.config import (
|
from ..utils.config import (
|
||||||
NETWORK_ROUTE_OPTIONS,
|
NETWORK_ROUTE_OPTIONS,
|
||||||
get_base_url_by_route,
|
get_base_url_by_route,
|
||||||
@@ -43,6 +43,14 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
PROGRESS_BAR_AVAILABLE = False
|
PROGRESS_BAR_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
||||||
|
INTERRUPT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
INTERRUPT_AVAILABLE = False
|
||||||
|
InterruptProcessingException = RuntimeError
|
||||||
|
processing_interrupted = lambda: False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
MEMORY_MONITOR_AVAILABLE = True
|
MEMORY_MONITOR_AVAILABLE = True
|
||||||
@@ -54,6 +62,8 @@ REQUEST_LOG_ENABLED = False
|
|||||||
|
|
||||||
_NODE = "Nano Banana"
|
_NODE = "Nano Banana"
|
||||||
_ENDPOINT = "/v1/chat/completions"
|
_ENDPOINT = "/v1/chat/completions"
|
||||||
|
_REQUEST_TIMEOUT = 900
|
||||||
|
_INTERRUPT_CHECK_INTERVAL = 0.2
|
||||||
|
|
||||||
_client_instance = None
|
_client_instance = None
|
||||||
|
|
||||||
@@ -65,6 +75,43 @@ def _get_client():
|
|||||||
return _client_instance
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_interrupt():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_INTERRUPT_CHECK_INTERVAL)
|
||||||
|
if INTERRUPT_AVAILABLE and processing_interrupted():
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_with_interrupt(coro):
|
||||||
|
if not INTERRUPT_AVAILABLE:
|
||||||
|
return await coro
|
||||||
|
|
||||||
|
request_task = asyncio.ensure_future(coro)
|
||||||
|
interrupt_task = asyncio.ensure_future(_poll_interrupt())
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[request_task, interrupt_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if interrupt_task in done and request_task not in done:
|
||||||
|
raise InterruptProcessingException()
|
||||||
|
|
||||||
|
return request_task.result()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_interrupt():
|
||||||
|
if INTERRUPT_AVAILABLE and processing_interrupted():
|
||||||
|
raise InterruptProcessingException()
|
||||||
|
|
||||||
|
|
||||||
def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.Tensor:
|
def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.Tensor:
|
||||||
if not images:
|
if not images:
|
||||||
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
|
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
|
||||||
@@ -143,22 +190,6 @@ def _build_request_body(
|
|||||||
enable_grounding: bool = False,
|
enable_grounding: bool = False,
|
||||||
thinking_level: Optional[str] = None,
|
thinking_level: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
content_parts = [{"type": "text", "text": prompt}]
|
|
||||||
|
|
||||||
if images:
|
|
||||||
for img in images:
|
|
||||||
b64 = encode_image_to_base64_limited(img, format="PNG")
|
|
||||||
content_parts.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": f"data:image/png;base64,{b64}"}
|
|
||||||
})
|
|
||||||
|
|
||||||
body = {
|
|
||||||
"model": model,
|
|
||||||
"stream": True,
|
|
||||||
"messages": [{"role": "user", "content": content_parts}],
|
|
||||||
}
|
|
||||||
|
|
||||||
google_config = {
|
google_config = {
|
||||||
"image_config": {
|
"image_config": {
|
||||||
"image_size": resolution,
|
"image_size": resolution,
|
||||||
@@ -171,13 +202,33 @@ def _build_request_body(
|
|||||||
"thinking_level": thinking_level.lower(),
|
"thinking_level": thinking_level.lower(),
|
||||||
"include_thoughts": True,
|
"include_thoughts": True,
|
||||||
}
|
}
|
||||||
body["extra_body"] = {"google": google_config}
|
|
||||||
|
def _make_body(encoded_images: Optional[List[tuple]] = None) -> dict:
|
||||||
|
content_parts = [{"type": "text", "text": prompt}]
|
||||||
|
if encoded_images:
|
||||||
|
for mime_type, b64 in encoded_images:
|
||||||
|
content_parts.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:{mime_type};base64,{b64}"}
|
||||||
|
})
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"stream": True,
|
||||||
|
"messages": [{"role": "user", "content": content_parts}],
|
||||||
|
"extra_body": {"google": google_config},
|
||||||
|
}
|
||||||
|
|
||||||
if enable_grounding:
|
if enable_grounding:
|
||||||
body["extra_body"]["google_search"] = True
|
body["extra_body"]["google_search"] = True
|
||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
encoded_images = None
|
||||||
|
if images:
|
||||||
|
encoded_images = encode_images_for_image_size_limit(images)
|
||||||
|
|
||||||
|
return _make_body(encoded_images)
|
||||||
|
|
||||||
|
|
||||||
async def _generate_single(
|
async def _generate_single(
|
||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
@@ -208,8 +259,11 @@ async def _generate_single(
|
|||||||
print(f"[请求] POST {url} | model={model} | extra_body={extra}")
|
print(f"[请求] POST {url} | model={model} | extra_body={extra}")
|
||||||
|
|
||||||
last_status = None
|
last_status = None
|
||||||
|
resp = None
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=30, sock_read=_REQUEST_TIMEOUT)
|
||||||
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
||||||
resp = await session.post(url, headers=headers, json=body)
|
_check_interrupt()
|
||||||
|
resp = await session.post(url, headers=headers, json=body, timeout=timeout)
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
break
|
break
|
||||||
last_status = resp.status
|
last_status = resp.status
|
||||||
@@ -239,7 +293,9 @@ async def _generate_single(
|
|||||||
buffer = ""
|
buffer = ""
|
||||||
t_request = time.time()
|
t_request = time.time()
|
||||||
t_first_token = None
|
t_first_token = None
|
||||||
|
try:
|
||||||
async for raw_chunk in resp.content.iter_any():
|
async for raw_chunk in resp.content.iter_any():
|
||||||
|
_check_interrupt()
|
||||||
if t_first_token is None:
|
if t_first_token is None:
|
||||||
t_first_token = time.time()
|
t_first_token = time.time()
|
||||||
buffer += raw_chunk.decode("utf-8")
|
buffer += raw_chunk.decode("utf-8")
|
||||||
@@ -258,8 +314,15 @@ async def _generate_single(
|
|||||||
full_content += delta["content"]
|
full_content += delta["content"]
|
||||||
except (json.JSONDecodeError, IndexError):
|
except (json.JSONDecodeError, IndexError):
|
||||||
continue
|
continue
|
||||||
t_done = time.time()
|
except aiohttp.ClientPayloadError as e:
|
||||||
|
if full_content and _IMAGE_RE.search(full_content):
|
||||||
|
print(f"Nano Banana: 响应流提前结束,但已收到完整图片,继续解析 ({e})")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"响应流下载中断,请重试或检查网络/代理: {e}") from None
|
||||||
|
finally:
|
||||||
|
if resp is not None:
|
||||||
resp.close()
|
resp.close()
|
||||||
|
t_done = time.time()
|
||||||
|
|
||||||
if not full_content:
|
if not full_content:
|
||||||
raise RuntimeError("API 未返回有效内容")
|
raise RuntimeError("API 未返回有效内容")
|
||||||
@@ -316,6 +379,8 @@ async def _generate_single_task(
|
|||||||
result["output_images"] = gen_images
|
result["output_images"] = gen_images
|
||||||
result["success"] = True
|
result["success"] = True
|
||||||
result["generated_count"] = len(gen_images)
|
result["generated_count"] = len(gen_images)
|
||||||
|
except InterruptProcessingException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["error"] = str(e)
|
result["error"] = str(e)
|
||||||
return result
|
return result
|
||||||
@@ -352,11 +417,13 @@ async def _process_batch_async(
|
|||||||
|
|
||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
for batch_idx in range(num_batches):
|
for batch_idx in range(num_batches):
|
||||||
|
_check_interrupt()
|
||||||
start_idx = batch_idx * max_concurrent
|
start_idx = batch_idx * max_concurrent
|
||||||
end_idx = min(start_idx + max_concurrent, total_tasks)
|
end_idx = min(start_idx + max_concurrent, total_tasks)
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for i in range(start_idx, end_idx):
|
for i in range(start_idx, end_idx):
|
||||||
|
_check_interrupt()
|
||||||
_, _, prompt = tasks_def[i]
|
_, _, prompt = tasks_def[i]
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
_generate_single_task(
|
_generate_single_task(
|
||||||
@@ -377,6 +444,7 @@ async def _process_batch_async(
|
|||||||
|
|
||||||
batch_results = []
|
batch_results = []
|
||||||
for coro in asyncio.as_completed(tasks):
|
for coro in asyncio.as_completed(tasks):
|
||||||
|
_check_interrupt()
|
||||||
result_data = None
|
result_data = None
|
||||||
try:
|
try:
|
||||||
result = await coro
|
result = await coro
|
||||||
@@ -384,6 +452,11 @@ async def _process_batch_async(
|
|||||||
result_data = {"success": False, "error": str(result), "generated_count": 0, "output_images": [], "prompt": ""}
|
result_data = {"success": False, "error": str(result), "generated_count": 0, "output_images": [], "prompt": ""}
|
||||||
else:
|
else:
|
||||||
result_data = result
|
result_data = result
|
||||||
|
except InterruptProcessingException:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result_data = {"success": False, "error": str(e), "generated_count": 0, "output_images": [], "prompt": ""}
|
result_data = {"success": False, "error": str(e), "generated_count": 0, "output_images": [], "prompt": ""}
|
||||||
|
|
||||||
@@ -472,6 +545,7 @@ class NanoBanana(io.ComfyNode):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, prompt, 模型, 生图数量, 计费, 网络, 谷歌搜索, seed, **kwargs) -> io.NodeOutput:
|
def execute(cls, prompt, 模型, 生图数量, 计费, 网络, 谷歌搜索, seed, **kwargs) -> io.NodeOutput:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
was_interrupted = False
|
||||||
|
|
||||||
model_name = 模型["模型"]
|
model_name = 模型["模型"]
|
||||||
宽高比 = 模型["宽高比"]
|
宽高比 = 模型["宽高比"]
|
||||||
@@ -533,7 +607,7 @@ class NanoBanana(io.ComfyNode):
|
|||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
try:
|
try:
|
||||||
return loop.run_until_complete(
|
return loop.run_until_complete(
|
||||||
_process_batch_async(
|
_run_with_interrupt(_process_batch_async(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
prompts=prompts,
|
prompts=prompts,
|
||||||
@@ -545,7 +619,7 @@ class NanoBanana(io.ComfyNode):
|
|||||||
pbar=pbar,
|
pbar=pbar,
|
||||||
enable_grounding=enable_grounding,
|
enable_grounding=enable_grounding,
|
||||||
thinking_level=thinking_level,
|
thinking_level=thinking_level,
|
||||||
)
|
))
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
@@ -553,9 +627,9 @@ class NanoBanana(io.ComfyNode):
|
|||||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
future = executor.submit(run_async_in_thread)
|
future = executor.submit(run_async_in_thread)
|
||||||
try:
|
try:
|
||||||
results = future.result(timeout=900)
|
results = future.result(timeout=_REQUEST_TIMEOUT)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise RuntimeError("任务执行超时(900秒)")
|
raise RuntimeError(f"任务执行超时({_REQUEST_TIMEOUT}秒)")
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r.get("success", False))
|
success_count = sum(1 for r in results if r.get("success", False))
|
||||||
fail_count = len(results) - success_count
|
fail_count = len(results) - success_count
|
||||||
@@ -594,13 +668,13 @@ class NanoBanana(io.ComfyNode):
|
|||||||
enable_grounding=enable_grounding,
|
enable_grounding=enable_grounding,
|
||||||
thinking_level=thinking_level,
|
thinking_level=thinking_level,
|
||||||
)
|
)
|
||||||
return loop.run_until_complete(_do())
|
return loop.run_until_complete(_run_with_interrupt(_do()))
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
future = executor.submit(run_single)
|
future = executor.submit(run_single)
|
||||||
generated_images, first_token_ms, download_ms = future.result(timeout=900)
|
generated_images, first_token_ms, download_ms = future.result(timeout=_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
if pbar is not None:
|
if pbar is not None:
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
@@ -615,6 +689,10 @@ class NanoBanana(io.ComfyNode):
|
|||||||
import gc; gc.collect()
|
import gc; gc.collect()
|
||||||
return io.NodeOutput(output_tensor)
|
return io.NodeOutput(output_tensor)
|
||||||
|
|
||||||
|
except InterruptProcessingException:
|
||||||
|
was_interrupted = True
|
||||||
|
print("Nano Banana: 用户取消")
|
||||||
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
if str(e) == "未授权!":
|
if str(e) == "未授权!":
|
||||||
print("请联系作者授权后方可使用!")
|
print("请联系作者授权后方可使用!")
|
||||||
@@ -625,6 +703,7 @@ class NanoBanana(io.ComfyNode):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise type(e)(str(e)) from None
|
raise type(e)(str(e)) from None
|
||||||
finally:
|
finally:
|
||||||
|
if not was_interrupted:
|
||||||
try:
|
try:
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
client.base_url = base_url
|
client.base_url = base_url
|
||||||
|
|||||||
+11
-3
@@ -28,12 +28,15 @@ HTTP_ERROR_MESSAGES = {
|
|||||||
|
|
||||||
# 错误内容关键词 → 用户友好文案(优先于状态码匹配)
|
# 错误内容关键词 → 用户友好文案(优先于状态码匹配)
|
||||||
ERROR_CONTENT_MESSAGES = {
|
ERROR_CONTENT_MESSAGES = {
|
||||||
|
"Your request was rejected by the safety system": "请求被安全系统拦截:请调整提示词,避免敏感、违规、血腥、色情、仇恨、未成年人或真实人物等高风险内容。",
|
||||||
|
"safety system": "请求被安全系统拦截:请调整提示词,避免敏感、违规、血腥、色情、仇恨、未成年人或真实人物等高风险内容。",
|
||||||
|
"unexpected end of JSON input": "通常重试能解决;反复出现就降低分辨率、数量或换网络线路。",
|
||||||
"The current model has a high load": "模型过载,请稍后重试!",
|
"The current model has a high load": "模型过载,请稍后重试!",
|
||||||
"system error": "系统错误,请稍后重试。",
|
"system error": "系统错误,请稍后重试。",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 可退避重试的状态码
|
# 可退避重试的状态码
|
||||||
RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
RETRYABLE_STATUS_CODES = {429, 502, 503, 504, 524}
|
||||||
|
|
||||||
# 退避重试默认参数
|
# 退避重试默认参数
|
||||||
DEFAULT_MAX_RETRIES = 3
|
DEFAULT_MAX_RETRIES = 3
|
||||||
@@ -44,10 +47,15 @@ DEFAULT_BACKOFF_FACTOR = 2.0 # 指数退避因子
|
|||||||
|
|
||||||
def get_friendly_message(status_code: int, raw_message: str = "") -> str:
|
def get_friendly_message(status_code: int, raw_message: str = "") -> str:
|
||||||
"""根据状态码/错误内容返回友好文案,未匹配则返回原始信息"""
|
"""根据状态码/错误内容返回友好文案,未匹配则返回原始信息"""
|
||||||
|
if status_code == 524:
|
||||||
|
return "Gateway timed out while waiting for upstream image generation. Please retry, lower resolution/count, or switch network route."
|
||||||
if raw_message:
|
if raw_message:
|
||||||
|
raw_message_lower = raw_message.lower()
|
||||||
for keyword, friendly_msg in ERROR_CONTENT_MESSAGES.items():
|
for keyword, friendly_msg in ERROR_CONTENT_MESSAGES.items():
|
||||||
if keyword in raw_message:
|
if keyword.lower() in raw_message_lower:
|
||||||
return friendly_msg
|
return friendly_msg
|
||||||
|
if status_code == 500:
|
||||||
|
return "服务器返回 500:上游生成失败或服务端临时异常。请稍后重试;如果多次出现,请降低分辨率/数量,或调整提示词。"
|
||||||
friendly = HTTP_ERROR_MESSAGES.get(status_code)
|
friendly = HTTP_ERROR_MESSAGES.get(status_code)
|
||||||
if friendly:
|
if friendly:
|
||||||
return friendly
|
return friendly
|
||||||
@@ -88,7 +96,7 @@ async def async_request_with_retry(
|
|||||||
"""
|
"""
|
||||||
带退避重试的 aiohttp 请求。
|
带退避重试的 aiohttp 请求。
|
||||||
|
|
||||||
仅对 RETRYABLE_STATUS_CODES (429/503/504) 进行重试。
|
仅对 RETRYABLE_STATUS_CODES (429/502/503/504/524) 进行重试。
|
||||||
超过最大重试次数后抛出友好 RuntimeError。
|
超过最大重试次数后抛出友好 RuntimeError。
|
||||||
成功时返回 response 对象(调用者需在 async with 外自行处理 body)。
|
成功时返回 response 对象(调用者需在 async with 外自行处理 body)。
|
||||||
|
|
||||||
|
|||||||
+126
-2
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import List
|
import json
|
||||||
|
from typing import Callable, List, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -110,7 +111,130 @@ def encode_image_to_base64(image: Image.Image, format: str = "PNG") -> str:
|
|||||||
return base64.b64encode(img_bytes).decode('utf-8')
|
return base64.b64encode(img_bytes).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10MB base64 上限
|
_MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024 # 50MB 请求体上限
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_image_to_base64_with_quality(image: Image.Image, quality: int) -> str:
|
||||||
|
buffered = BytesIO()
|
||||||
|
working = image
|
||||||
|
if working.mode != 'RGB':
|
||||||
|
working = working.convert('RGB')
|
||||||
|
|
||||||
|
working.save(
|
||||||
|
buffered,
|
||||||
|
format="JPEG",
|
||||||
|
quality=quality,
|
||||||
|
optimize=True,
|
||||||
|
subsampling=2,
|
||||||
|
)
|
||||||
|
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def encode_images_for_request_body_limit(
|
||||||
|
images: List[Image.Image],
|
||||||
|
build_body: Callable[[List[Tuple[str, str]]], dict],
|
||||||
|
max_body_bytes: int = _MAX_REQUEST_BODY_BYTES,
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
为请求体编码图片,并保证完整 JSON 请求体不超过 max_body_bytes。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 先按原始 PNG 编码估算完整请求体;
|
||||||
|
- 若超过限制,改用 JPEG 质量压缩,逐步降低 quality;
|
||||||
|
- 全程不缩放图片尺寸。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(mime_type, base64), ...]
|
||||||
|
"""
|
||||||
|
encoded = [("image/png", encode_image_to_base64(img, format="PNG")) for img in images]
|
||||||
|
body_size = len(json.dumps(build_body(encoded)).encode("utf-8"))
|
||||||
|
if body_size <= max_body_bytes:
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
for quality in [95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 1]:
|
||||||
|
encoded = [
|
||||||
|
("image/jpeg", _encode_image_to_base64_with_quality(img, quality))
|
||||||
|
for img in images
|
||||||
|
]
|
||||||
|
body_size = len(json.dumps(build_body(encoded)).encode("utf-8"))
|
||||||
|
if body_size <= max_body_bytes:
|
||||||
|
print(
|
||||||
|
f"输入图片已通过 JPEG 质量压缩控制请求体积: "
|
||||||
|
f"quality={quality}, 请求体积={body_size / 1024 / 1024:.2f}MB "
|
||||||
|
f"(限制 {max_body_bytes / 1024 / 1024:.0f}MB)"
|
||||||
|
)
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"请求体超过 {max_body_bytes / 1024 / 1024:.0f}MB,"
|
||||||
|
"即使压缩到最低图片质量仍无法满足限制;请减少参考图数量或输入图片内容复杂度"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10MB 单张图片上限
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_image_to_bytes(image: Image.Image, format: str = "PNG", quality: int = None) -> bytes:
|
||||||
|
buffered = BytesIO()
|
||||||
|
working = image
|
||||||
|
|
||||||
|
if format.upper() == "JPEG" and working.mode != 'RGB':
|
||||||
|
working = working.convert('RGB')
|
||||||
|
elif working.mode == 'RGBA':
|
||||||
|
working = working.convert('RGB')
|
||||||
|
|
||||||
|
save_kwargs = {"format": format}
|
||||||
|
if quality is not None:
|
||||||
|
save_kwargs.update({
|
||||||
|
"quality": quality,
|
||||||
|
"optimize": True,
|
||||||
|
"subsampling": 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
working.save(buffered, **save_kwargs)
|
||||||
|
return buffered.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def encode_images_for_image_size_limit(
|
||||||
|
images: List[Image.Image],
|
||||||
|
max_image_bytes: int = _MAX_IMAGE_BYTES,
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
将图片编码为 base64,并保证每张编码前的图片文件体积不超过 max_image_bytes。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 先尝试 PNG 原图尺寸编码;
|
||||||
|
- 单张超过限制时,改用 JPEG 质量压缩;
|
||||||
|
- 全程不缩放图片尺寸。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(mime_type, base64), ...]
|
||||||
|
"""
|
||||||
|
encoded = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(images, start=1):
|
||||||
|
png_bytes = _encode_image_to_bytes(img, format="PNG")
|
||||||
|
if len(png_bytes) <= max_image_bytes:
|
||||||
|
encoded.append(("image/png", base64.b64encode(png_bytes).decode('utf-8')))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for quality in [95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 1]:
|
||||||
|
jpg_bytes = _encode_image_to_bytes(img, format="JPEG", quality=quality)
|
||||||
|
if len(jpg_bytes) <= max_image_bytes:
|
||||||
|
print(
|
||||||
|
f"输入图片 {idx} 已通过 JPEG 质量压缩控制单图体积: "
|
||||||
|
f"quality={quality}, 图片体积={len(jpg_bytes) / 1024 / 1024:.2f}MB "
|
||||||
|
f"(限制 {max_image_bytes / 1024 / 1024:.0f}MB),尺寸保持 {img.width}x{img.height}"
|
||||||
|
)
|
||||||
|
encoded.append(("image/jpeg", base64.b64encode(jpg_bytes).decode('utf-8')))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"输入图片 {idx} 超过 {max_image_bytes / 1024 / 1024:.0f}MB,"
|
||||||
|
"即使压缩到最低图片质量仍无法满足限制;请减少图片内容复杂度或手动处理图片"
|
||||||
|
)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
def encode_image_to_base64_limited(
|
def encode_image_to_base64_limited(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
基于 rembg 库实现,支持 CPU 推理
|
基于 rembg 库实现,支持 CPU 推理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -14,6 +15,13 @@ def _get_session():
|
|||||||
"""懒加载 rembg session,避免启动时加载模型"""
|
"""懒加载 rembg session,避免启动时加载模型"""
|
||||||
global _session
|
global _session
|
||||||
if _session is None:
|
if _session is None:
|
||||||
|
try:
|
||||||
|
import folder_paths
|
||||||
|
models_dir = os.path.join(folder_paths.models_dir, "rembg")
|
||||||
|
os.makedirs(models_dir, exist_ok=True)
|
||||||
|
os.environ["U2NET_HOME"] = models_dir
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
from rembg import new_session
|
from rembg import new_session
|
||||||
_session = new_session("isnet-general-use")
|
_session = new_session("isnet-general-use")
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
||||||
|
INTERRUPT_AVAILABLE = True
|
||||||
|
except Exception:
|
||||||
|
INTERRUPT_AVAILABLE = False
|
||||||
|
processing_interrupted = lambda: False
|
||||||
|
|
||||||
|
class InterruptProcessingException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
SUCCESS_STATUSES = {"succeed", "succeeded", "success", "completed", "done", "finished"}
|
||||||
|
FAILURE_STATUSES = {
|
||||||
|
"fail",
|
||||||
|
"failed",
|
||||||
|
"failure",
|
||||||
|
"error",
|
||||||
|
"expired",
|
||||||
|
"timeout",
|
||||||
|
"timed_out",
|
||||||
|
"cancel",
|
||||||
|
"canceled",
|
||||||
|
"cancelled",
|
||||||
|
"rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_interrupt() -> None:
|
||||||
|
if INTERRUPT_AVAILABLE and processing_interrupted():
|
||||||
|
raise InterruptProcessingException()
|
||||||
|
|
||||||
|
|
||||||
|
async def interruptible_sleep(seconds: float, step: float = 0.2) -> None:
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < seconds:
|
||||||
|
check_interrupt()
|
||||||
|
delay = min(step, seconds - elapsed)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
elapsed += delay
|
||||||
|
check_interrupt()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_with_interrupt(coro, step: float = 0.2):
|
||||||
|
task = asyncio.ensure_future(coro)
|
||||||
|
try:
|
||||||
|
while not task.done():
|
||||||
|
check_interrupt()
|
||||||
|
await asyncio.wait({task}, timeout=step)
|
||||||
|
check_interrupt()
|
||||||
|
return await task
|
||||||
|
except InterruptProcessingException:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_payloads(payload: Dict[str, Any]):
|
||||||
|
root = _as_dict(payload)
|
||||||
|
data = _as_dict(root.get("data"))
|
||||||
|
inner = _as_dict(data.get("data"))
|
||||||
|
return root, data, inner
|
||||||
|
|
||||||
|
|
||||||
|
def extract_status(payload: Dict[str, Any]) -> str:
|
||||||
|
root, data, inner = _nested_payloads(payload)
|
||||||
|
keys = ("status", "task_status", "state", "task_state")
|
||||||
|
statuses = []
|
||||||
|
for source in (data, inner, root):
|
||||||
|
for key in keys:
|
||||||
|
value = source.get(key)
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
statuses.append(str(value).strip().lower())
|
||||||
|
for status in statuses:
|
||||||
|
if status in FAILURE_STATUSES or any(
|
||||||
|
token in status for token in ("fail", "error", "reject", "timeout", "cancel")
|
||||||
|
):
|
||||||
|
return status
|
||||||
|
for status in statuses:
|
||||||
|
if status in SUCCESS_STATUSES:
|
||||||
|
return status
|
||||||
|
return statuses[0] if statuses else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_progress(payload: Dict[str, Any]) -> int:
|
||||||
|
root, data, inner = _nested_payloads(payload)
|
||||||
|
for source in (data, inner, root):
|
||||||
|
value = source.get("progress")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return max(0, min(100, int(float(str(value).strip().rstrip("%")))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def extract_error_message(payload: Dict[str, Any], default: str = "未知错误") -> str:
|
||||||
|
root, data, inner = _nested_payloads(payload)
|
||||||
|
keys = (
|
||||||
|
"fail_reason",
|
||||||
|
"failure_reason",
|
||||||
|
"task_status_msg",
|
||||||
|
"status_msg",
|
||||||
|
"error_message",
|
||||||
|
"message",
|
||||||
|
"msg",
|
||||||
|
"reason",
|
||||||
|
"detail",
|
||||||
|
"details",
|
||||||
|
)
|
||||||
|
for source in (data, inner, root):
|
||||||
|
error = source.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
for key in ("message", "msg", "detail", "reason", "code"):
|
||||||
|
value = error.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
elif error:
|
||||||
|
return str(error)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
value = source.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_url(payload: Dict[str, Any]) -> str | None:
|
||||||
|
root, data, inner = _nested_payloads(payload)
|
||||||
|
for source in (data, inner, root):
|
||||||
|
for key in ("video_url", "result_url", "url", "download_url"):
|
||||||
|
value = source.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
result = _as_dict(source.get("result"))
|
||||||
|
for key in ("video_url", "result_url", "url", "download_url"):
|
||||||
|
value = result.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
content = _as_dict(source.get("content"))
|
||||||
|
value = content.get("video_url") or content.get("url")
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
task_result = _as_dict(source.get("task_result"))
|
||||||
|
videos = task_result.get("videos")
|
||||||
|
if isinstance(videos, list) and videos:
|
||||||
|
first = _as_dict(videos[0])
|
||||||
|
value = first.get("url") or first.get("video_url")
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_success_status(status: str) -> bool:
|
||||||
|
return status in SUCCESS_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def is_failure_status(status: str, payload: Dict[str, Any] | None = None) -> bool:
|
||||||
|
if status in FAILURE_STATUSES:
|
||||||
|
return True
|
||||||
|
if any(token in status for token in ("fail", "error", "reject", "timeout", "cancel")):
|
||||||
|
return True
|
||||||
|
if payload is None:
|
||||||
|
return False
|
||||||
|
root, data, inner = _nested_payloads(payload)
|
||||||
|
failure_keys = ("error", "fail_reason", "failure_reason", "task_status_msg", "error_message")
|
||||||
|
return any(any(source.get(key) for key in failure_keys) for source in (data, inner, root))
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
import { app } from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "o1key-notes";
|
||||||
|
const SEEDED_KEY = "o1key-notes-seeded-v2";
|
||||||
|
const STYLE_ID = "o1key-notes-styles";
|
||||||
|
|
||||||
|
let notes = [];
|
||||||
|
let searchText = "";
|
||||||
|
let activeFilter = "all";
|
||||||
|
let noteContainer = null;
|
||||||
|
let editingNoteId = null;
|
||||||
|
let draftNote = null;
|
||||||
|
let pendingDelete = false;
|
||||||
|
|
||||||
|
const SAMPLE_NOTES = [
|
||||||
|
{
|
||||||
|
title: "产品主图:高级玻璃质感",
|
||||||
|
tags: ["产品图", "玻璃", "灯光"],
|
||||||
|
content: `Clean studio lighting, translucent glass material, subtle caustics, soft shadow, premium product photography, 85mm lens, minimal background, high detail.
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
1. 把产品图作为参考图输入
|
||||||
|
2. 保留主体轮廓,只调整材质和灯光
|
||||||
|
3. 如果玻璃过亮,降低 "caustics" 权重`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Nano Banana 参考图经验",
|
||||||
|
tags: ["Nano Banana", "参考图"],
|
||||||
|
content: `参考图越多越容易跑偏,主体一致性优先用 1-3 张图。
|
||||||
|
|
||||||
|
复杂场景建议分两步:
|
||||||
|
1. 先生成主体和构图
|
||||||
|
2. 再用局部或参考图做材质、背景、文字等精修
|
||||||
|
|
||||||
|
如果提示词和参考图冲突,模型通常会优先参考图。`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "电商模特换装模板",
|
||||||
|
tags: ["电商", "模特", "换装"],
|
||||||
|
content: `Keep face identity and original pose. Replace the outfit with: [服装描述].
|
||||||
|
Realistic fabric texture, natural folds, accurate seams, studio e-commerce photography, clean background, consistent lighting.
|
||||||
|
|
||||||
|
Avoid changing body shape, face, hairstyle, camera angle, or hand position.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "常用负向词",
|
||||||
|
tags: ["负向词", "通用"],
|
||||||
|
content: "low quality, blurry, deformed hands, extra fingers, bad anatomy, distorted text, watermark, logo, oversaturated, plastic skin, broken geometry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "图片批量命名经验",
|
||||||
|
tags: ["批量", "工作流"],
|
||||||
|
content: `批量生图前先确认保存路径和命名规则。
|
||||||
|
|
||||||
|
推荐流程:
|
||||||
|
1. 小批量跑 2-3 张确认风格
|
||||||
|
2. 固定提示词和参考图
|
||||||
|
3. 再放大批量数量
|
||||||
|
|
||||||
|
这样失败成本最低,也更容易定位是哪一环导致跑偏。`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
#o1key-notes-root{position:absolute;inset:0;display:flex;flex-direction:column;min-height:0;color:#ddd;background:var(--comfy-menu-bg,#202020);overflow:hidden;font-family:inherit;--o1n-blue:#4f8cff;--o1n-blue-2:#7eb8f7;--o1n-blue-soft:rgba(79,140,255,.14)}
|
||||||
|
#o1key-notes-header{padding:12px 14px 10px;border-bottom:1px solid rgba(255,255,255,.06);flex-shrink:0}
|
||||||
|
.o1n-title-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||||
|
.o1n-title{font-size:14px;font-weight:700;color:#eee;letter-spacing:.2px}
|
||||||
|
.o1n-head-actions{display:flex;gap:5px}
|
||||||
|
.o1n-icon-btn{width:28px;height:28px;border:1px solid rgba(255,255,255,.09);border-radius:7px;background:rgba(255,255,255,.035);color:#888;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;flex-shrink:0}
|
||||||
|
.o1n-icon-btn:hover{color:#ddd;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.16)}
|
||||||
|
.o1n-icon-btn.primary{background:var(--o1n-blue);color:#fff;border-color:transparent}
|
||||||
|
.o1n-icon-btn.primary:hover{background:#6ca0ff;color:#fff}
|
||||||
|
#o1n-search-box{height:34px;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:rgba(255,255,255,.045);display:flex;align-items:center;gap:8px;padding:0 10px;transition:border-color .15s}
|
||||||
|
#o1n-search-box:focus-within{border-color:rgba(79,140,255,.42)}
|
||||||
|
#o1n-search{flex:1;background:transparent;border:0;outline:0;color:#ddd;font-size:12px;min-width:0}
|
||||||
|
#o1n-search::placeholder{color:#666}
|
||||||
|
#o1n-panel-status{height:16px;margin-top:7px;color:#777;font-size:11px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.o1n-tag-strip{display:flex;gap:6px;margin-top:10px;overflow-x:auto;padding-bottom:1px}
|
||||||
|
.o1n-tag-strip::-webkit-scrollbar{display:none}
|
||||||
|
.o1n-tag-filter{height:26px;padding:0 9px;border-radius:7px;border:1px solid rgba(255,255,255,.09);background:transparent;color:#929292;font-size:12px;display:flex;align-items:center;gap:5px;cursor:pointer;white-space:nowrap;transition:all .15s}
|
||||||
|
.o1n-tag-filter:hover{color:#cfcfcf;border-color:rgba(255,255,255,.16)}
|
||||||
|
.o1n-tag-filter.active{color:var(--o1n-blue-2);border-color:rgba(79,140,255,.36);background:var(--o1n-blue-soft)}
|
||||||
|
#o1key-notes-list{flex:1;min-height:0;overflow:auto;padding:8px 10px 12px;display:flex;flex-direction:column;gap:6px}
|
||||||
|
#o1key-notes-list::-webkit-scrollbar,#o1n-content::-webkit-scrollbar{width:4px}
|
||||||
|
#o1key-notes-list::-webkit-scrollbar-thumb,#o1n-content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:2px}
|
||||||
|
.o1n-item{border:1px solid transparent;border-radius:8px;padding:10px;background:transparent;cursor:pointer;transition:all .12s}
|
||||||
|
.o1n-item:hover{background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.07)}
|
||||||
|
.o1n-item.active{background:rgba(255,255,255,.065);border-color:rgba(255,255,255,.12);box-shadow:inset 2px 0 0 var(--o1n-blue)}
|
||||||
|
.o1n-item-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
||||||
|
.o1n-item-title{font-size:13px;color:#e0e0e0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.o1n-item-edit{font-size:11px;color:#777;flex-shrink:0}
|
||||||
|
.o1n-item-text{font-size:12px;line-height:1.45;color:#8b8b8b;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
|
||||||
|
.o1n-item-meta{margin-top:8px;display:flex;align-items:center;justify-content:space-between;gap:8px;color:#666;font-size:10px}
|
||||||
|
.o1n-item-tags{display:flex;gap:4px;overflow:hidden;min-width:0}
|
||||||
|
.o1n-tag{color:var(--o1n-blue-2);background:rgba(79,140,255,.1);border:1px solid rgba(79,140,255,.2);border-radius:5px;padding:2px 5px;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.o1n-empty{height:100%;display:flex;align-items:center;justify-content:center;text-align:center;color:#666;font-size:13px;line-height:1.6;padding:20px}
|
||||||
|
.o1n-editor-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.34);display:flex;align-items:stretch;justify-content:flex-end;z-index:30;animation:o1n-fade .12s ease}
|
||||||
|
.o1n-editor{width:100%;height:100%;background:var(--comfy-menu-bg,#202020);border-left:1px solid rgba(79,140,255,.28);box-shadow:0 22px 70px rgba(0,0,0,.46);display:flex;flex-direction:column;min-height:0}
|
||||||
|
.o1n-editor-top{height:48px;padding:0 14px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||||
|
.o1n-editor-title{font-size:13px;font-weight:700;color:#eee}
|
||||||
|
.o1n-editor-body{padding:12px;display:flex;flex-direction:column;gap:9px;flex:1;min-height:0}
|
||||||
|
#o1n-title-input,#o1n-content{width:100%;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:rgba(255,255,255,.045);color:#ddd;outline:0;font-family:inherit;transition:border-color .15s}
|
||||||
|
#o1n-title-input{height:34px;padding:0 10px;font-size:13px;font-weight:700;flex-shrink:0}
|
||||||
|
#o1n-content{flex:1;min-height:160px;resize:none;padding:11px 12px;font-size:13px;line-height:1.58}
|
||||||
|
#o1n-title-input:focus,#o1n-content:focus{border-color:rgba(79,140,255,.42)}
|
||||||
|
.o1n-tag-editor{min-height:74px;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:rgba(255,255,255,.035);padding:7px;flex-shrink:0}
|
||||||
|
.o1n-tag-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||||
|
.o1n-tag-token{height:24px;padding:0 7px;border-radius:6px;border:1px solid rgba(79,140,255,.25);background:rgba(79,140,255,.1);color:var(--o1n-blue-2);display:inline-flex;align-items:center;gap:6px;font-size:12px}
|
||||||
|
.o1n-tag-remove{color:#8baee8;font-size:13px;line-height:1;cursor:pointer}
|
||||||
|
.o1n-tag-input{height:24px;min-width:104px;flex:1;border:1px dashed rgba(255,255,255,.14);border-radius:6px;background:transparent;color:#ddd;padding:0 7px;font-size:12px;outline:0}
|
||||||
|
.o1n-tag-input::placeholder{color:#777}
|
||||||
|
.o1n-tag-hint{margin-top:7px;color:#666;font-size:11px;line-height:1.35}
|
||||||
|
.o1n-editor-actions{display:grid;grid-template-columns:1fr 1fr 1fr;gap:7px;padding:12px;border-top:1px solid rgba(255,255,255,.08);flex-shrink:0}
|
||||||
|
.o1n-action{height:32px;border:1px solid rgba(255,255,255,.1);border-radius:7px;background:rgba(255,255,255,.035);color:#aaa;font-size:12px;display:flex;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all .15s}
|
||||||
|
.o1n-action:hover{color:#e5e5e5;background:rgba(255,255,255,.075);border-color:rgba(255,255,255,.16)}
|
||||||
|
.o1n-action.primary{background:var(--o1n-blue);color:#fff;border-color:transparent;font-weight:700}
|
||||||
|
.o1n-action.primary:hover{background:#6ca0ff;color:#fff}
|
||||||
|
.o1n-action.accent{background:#d8b45b;color:#171717;border-color:transparent;font-weight:700}
|
||||||
|
.o1n-action.accent:hover{background:#e3c474;color:#111}
|
||||||
|
.o1n-action.danger:hover{background:rgba(215,101,101,.18);border-color:rgba(215,101,101,.32);color:#f0a0a0}
|
||||||
|
.o1n-delete-confirm{grid-column:1 / -1;display:none;align-items:center;gap:7px;padding:7px 8px;border:1px solid rgba(215,101,101,.24);border-radius:7px;background:rgba(215,101,101,.08);color:#ccc;font-size:12px}
|
||||||
|
.o1n-delete-confirm.show{display:flex}
|
||||||
|
.o1n-delete-confirm span{flex:1;min-width:0}
|
||||||
|
.o1n-mini-btn{height:24px;border:1px solid rgba(255,255,255,.1);border-radius:6px;background:rgba(255,255,255,.06);color:#bbb;padding:0 8px;font-size:11px;cursor:pointer}
|
||||||
|
.o1n-mini-btn.danger{background:rgba(215,101,101,.5);border-color:rgba(215,101,101,.3);color:#fff}
|
||||||
|
#o1n-status{grid-column:1 / -1;height:16px;color:#777;font-size:11px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
#o1n-import-file{display:none}
|
||||||
|
@keyframes o1n-fade{from{opacity:0}to{opacity:1}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean);
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[,,、\s]+/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNote(data = {}) {
|
||||||
|
const ts = now();
|
||||||
|
return {
|
||||||
|
id: data.id || genId(),
|
||||||
|
title: String(data.title || "未命名笔记"),
|
||||||
|
tags: parseTags(data.tags || data.category || ""),
|
||||||
|
content: String(data.content || ""),
|
||||||
|
createdAt: data.createdAt || ts,
|
||||||
|
updatedAt: data.updatedAt || ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneNote(note) {
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
tags: [...(note.tags || [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
const d = new Date(ts || now());
|
||||||
|
const today = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
if (d.toDateString() === today.toDateString()) {
|
||||||
|
return `今天 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(name, size = 14) {
|
||||||
|
const icons = {
|
||||||
|
plus: `<path d="M12 5v14M5 12h14"/>`,
|
||||||
|
search: `<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>`,
|
||||||
|
copy: `<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>`,
|
||||||
|
insert: `<path d="M12 5v14"/><path d="M19 12H5"/>`,
|
||||||
|
save: `<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>`,
|
||||||
|
trash: `<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/>`,
|
||||||
|
download: `<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>`,
|
||||||
|
upload: `<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/>`,
|
||||||
|
node: `<rect x="3" y="4" width="7" height="7" rx="1"/><rect x="14" y="13" width="7" height="7" rx="1"/><path d="M10 7.5h3a4 4 0 014 4V13"/>`,
|
||||||
|
close: `<path d="M18 6L6 18M6 6l12 12"/>`,
|
||||||
|
};
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name] || icons.plus}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 loadNotes() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
notes = raw ? JSON.parse(raw).map(makeNote) : [];
|
||||||
|
} catch {
|
||||||
|
notes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localStorage.getItem(SEEDED_KEY)) {
|
||||||
|
const existingTitles = new Set(notes.map(n => n.title));
|
||||||
|
const samples = SAMPLE_NOTES.map(makeNote).filter(n => !existingTitles.has(n.title));
|
||||||
|
notes = [...samples, ...notes];
|
||||||
|
localStorage.setItem(SEEDED_KEY, "1");
|
||||||
|
saveNotes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNotes() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allTags() {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const note of notes) {
|
||||||
|
for (const tag of note.tags || []) counts.set(tag, (counts.get(tag) || 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredNotes() {
|
||||||
|
const q = searchText.trim().toLowerCase();
|
||||||
|
return notes.filter(note => {
|
||||||
|
if (activeFilter.startsWith("tag:") && !(note.tags || []).includes(activeFilter.slice(4))) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return [note.title, note.content, ...(note.tags || [])].join("\n").toLowerCase().includes(q);
|
||||||
|
}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "o1key.notePanel",
|
||||||
|
async setup() {
|
||||||
|
loadNotes();
|
||||||
|
app.extensionManager.registerSidebarTab({
|
||||||
|
id: "o1key-notes",
|
||||||
|
title: "笔记",
|
||||||
|
icon: "pi pi-pencil",
|
||||||
|
type: "custom",
|
||||||
|
render: (container) => {
|
||||||
|
injectStyles();
|
||||||
|
renderNotePanel(container);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderNotePanel(container) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.style.position = "relative";
|
||||||
|
container.style.height = "100%";
|
||||||
|
container.style.overflow = "hidden";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = "o1key-notes-root";
|
||||||
|
root.innerHTML = `
|
||||||
|
<div id="o1key-notes-header">
|
||||||
|
<div class="o1n-title-row">
|
||||||
|
<div class="o1n-title">笔记</div>
|
||||||
|
<div class="o1n-head-actions">
|
||||||
|
<button class="o1n-icon-btn" id="o1n-import" title="导入 JSON">${icon("upload")}</button>
|
||||||
|
<button class="o1n-icon-btn" id="o1n-export" title="导出 JSON">${icon("download")}</button>
|
||||||
|
<button class="o1n-icon-btn primary" id="o1n-new" title="新建笔记">${icon("plus")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="o1n-search-box">${icon("search", 13)}<input id="o1n-search" placeholder="搜索笔记内容或标签" value="${escapeHtml(searchText)}"></div>
|
||||||
|
<div class="o1n-tag-strip" id="o1n-tag-strip"></div>
|
||||||
|
<div id="o1n-panel-status"></div>
|
||||||
|
</div>
|
||||||
|
<div id="o1key-notes-list"></div>
|
||||||
|
<input id="o1n-import-file" type="file" accept=".json,application/json">
|
||||||
|
`;
|
||||||
|
container.appendChild(root);
|
||||||
|
noteContainer = root;
|
||||||
|
bindPanelEvents(root);
|
||||||
|
renderTagFilters();
|
||||||
|
renderList();
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPanelEvents(root) {
|
||||||
|
root.querySelector("#o1n-new").addEventListener("click", createNote);
|
||||||
|
root.querySelector("#o1n-export").addEventListener("click", exportNotes);
|
||||||
|
root.querySelector("#o1n-import").addEventListener("click", () => root.querySelector("#o1n-import-file").click());
|
||||||
|
root.querySelector("#o1n-import-file").addEventListener("change", importNotes);
|
||||||
|
root.querySelector("#o1n-search").addEventListener("input", (e) => {
|
||||||
|
searchText = e.target.value;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagFilters() {
|
||||||
|
const row = noteContainer?.querySelector("#o1n-tag-strip");
|
||||||
|
if (!row) return;
|
||||||
|
const tags = allTags();
|
||||||
|
row.innerHTML = `<button class="o1n-tag-filter${activeFilter === "all" ? " active" : ""}" data-filter="all">全部 <span>${notes.length}</span></button>` +
|
||||||
|
tags.map(([tag, count]) => {
|
||||||
|
const filter = `tag:${tag}`;
|
||||||
|
return `<button class="o1n-tag-filter${activeFilter === filter ? " active" : ""}" data-filter="${escapeHtml(filter)}">#${escapeHtml(tag)} <span>${count}</span></button>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
row.querySelectorAll("[data-filter]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
activeFilter = btn.dataset.filter;
|
||||||
|
renderTagFilters();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const list = noteContainer.querySelector("#o1key-notes-list");
|
||||||
|
const visible = filteredNotes();
|
||||||
|
|
||||||
|
if (!visible.length) {
|
||||||
|
list.innerHTML = `<div class="o1n-empty">没有匹配的笔记</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = visible.map(note => {
|
||||||
|
const tags = (note.tags || []).slice(0, 3).map(tag => `<span class="o1n-tag">#${escapeHtml(tag)}</span>`).join("");
|
||||||
|
return `<div class="o1n-item${note.id === editingNoteId ? " active" : ""}" data-id="${note.id}">
|
||||||
|
<div class="o1n-item-top">
|
||||||
|
<div class="o1n-item-title">${escapeHtml(note.title || "未命名笔记")}</div>
|
||||||
|
<div class="o1n-item-edit">编辑</div>
|
||||||
|
</div>
|
||||||
|
<div class="o1n-item-text">${escapeHtml(note.content || "空笔记")}</div>
|
||||||
|
<div class="o1n-item-meta"><div class="o1n-item-tags">${tags || `<span class="o1n-tag">#未分类</span>`}</div><span>${formatDate(note.updatedAt)}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
list.querySelectorAll(".o1n-item").forEach(item => {
|
||||||
|
item.addEventListener("click", () => openEditor(item.dataset.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
noteContainer.querySelector(".o1n-editor-backdrop")?.remove();
|
||||||
|
if (!draftNote) return;
|
||||||
|
pendingDelete = false;
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "o1n-editor-backdrop";
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="o1n-editor">
|
||||||
|
<div class="o1n-editor-top">
|
||||||
|
<div class="o1n-editor-title">${editingNoteId ? "编辑笔记" : "新建笔记"}</div>
|
||||||
|
<button class="o1n-icon-btn" id="o1n-close-editor" title="关闭">${icon("close", 13)}</button>
|
||||||
|
</div>
|
||||||
|
<div class="o1n-editor-body">
|
||||||
|
<input id="o1n-title-input" value="${escapeHtml(draftNote.title)}" placeholder="笔记标题">
|
||||||
|
<div class="o1n-tag-editor">
|
||||||
|
<div class="o1n-tag-row">
|
||||||
|
${(draftNote.tags || []).map(tag => `<span class="o1n-tag-token">${escapeHtml(tag)} <span class="o1n-tag-remove" data-tag="${escapeHtml(tag)}">×</span></span>`).join("")}
|
||||||
|
<input class="o1n-tag-input" id="o1n-tag-input" placeholder="+ 添加标签">
|
||||||
|
</div>
|
||||||
|
<div class="o1n-tag-hint">输入标签后按 Enter,或用逗号分隔多个标签。</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="o1n-content" placeholder="记录提示词、参数经验、踩坑结论...">${escapeHtml(draftNote.content)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="o1n-editor-actions">
|
||||||
|
<button class="o1n-action" id="o1n-cancel">取消</button>
|
||||||
|
<button class="o1n-action" id="o1n-copy">${icon("copy", 13)}复制</button>
|
||||||
|
<button class="o1n-action accent" id="o1n-insert">${icon("insert", 13)}插入</button>
|
||||||
|
<button class="o1n-action" id="o1n-save-from-node">${icon("node", 13)}从节点保存</button>
|
||||||
|
<button class="o1n-action danger" id="o1n-delete">${icon("trash", 13)}删除</button>
|
||||||
|
<button class="o1n-action primary" id="o1n-save">${icon("save", 13)}保存</button>
|
||||||
|
<div class="o1n-delete-confirm" id="o1n-delete-confirm">
|
||||||
|
<span>确定删除这条笔记?</span>
|
||||||
|
<button class="o1n-mini-btn danger" id="o1n-delete-yes">删除</button>
|
||||||
|
<button class="o1n-mini-btn" id="o1n-delete-no">取消</button>
|
||||||
|
</div>
|
||||||
|
<div id="o1n-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
noteContainer.appendChild(overlay);
|
||||||
|
bindEditorEvents(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEditorEvents(overlay) {
|
||||||
|
overlay.querySelector("#o1n-close-editor").addEventListener("click", closeEditor);
|
||||||
|
overlay.querySelector("#o1n-cancel").addEventListener("click", closeEditor);
|
||||||
|
overlay.querySelector("#o1n-title-input").addEventListener("input", updateDraftFromEditor);
|
||||||
|
overlay.querySelector("#o1n-content").addEventListener("input", updateDraftFromEditor);
|
||||||
|
overlay.querySelectorAll(".o1n-tag-remove").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => removeDraftTag(btn.dataset.tag));
|
||||||
|
});
|
||||||
|
overlay.querySelector("#o1n-tag-input").addEventListener("keydown", handleTagInputKeydown);
|
||||||
|
overlay.querySelector("#o1n-tag-input").addEventListener("blur", commitTagInput);
|
||||||
|
overlay.querySelector("#o1n-copy").addEventListener("click", copyDraftContent);
|
||||||
|
overlay.querySelector("#o1n-insert").addEventListener("click", insertDraftContent);
|
||||||
|
overlay.querySelector("#o1n-save-from-node").addEventListener("click", saveFromCurrentNode);
|
||||||
|
overlay.querySelector("#o1n-delete").addEventListener("click", requestDeleteEditingNote);
|
||||||
|
overlay.querySelector("#o1n-delete-yes").addEventListener("click", deleteEditingNote);
|
||||||
|
overlay.querySelector("#o1n-delete-no").addEventListener("click", hideDeleteConfirm);
|
||||||
|
overlay.querySelector("#o1n-save").addEventListener("click", saveDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(id) {
|
||||||
|
const note = notes.find(n => n.id === id);
|
||||||
|
if (!note) return;
|
||||||
|
editingNoteId = id;
|
||||||
|
draftNote = cloneNote(note);
|
||||||
|
renderList();
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editingNoteId = null;
|
||||||
|
draftNote = null;
|
||||||
|
pendingDelete = false;
|
||||||
|
renderList();
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNote() {
|
||||||
|
editingNoteId = null;
|
||||||
|
draftNote = makeNote({ title: "新笔记", tags: ["未分类"], content: "" });
|
||||||
|
renderList();
|
||||||
|
renderEditor();
|
||||||
|
noteContainer.querySelector("#o1n-title-input")?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraftFromEditor() {
|
||||||
|
if (!draftNote) return;
|
||||||
|
draftNote.title = noteContainer.querySelector("#o1n-title-input")?.value.trim() || "未命名笔记";
|
||||||
|
draftNote.content = noteContainer.querySelector("#o1n-content")?.value || "";
|
||||||
|
draftNote.updatedAt = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagsFromText(value) {
|
||||||
|
if (!draftNote) return false;
|
||||||
|
const incoming = parseTags(value);
|
||||||
|
if (!incoming.length) return false;
|
||||||
|
const existing = new Set(draftNote.tags || []);
|
||||||
|
for (const tag of incoming) existing.add(tag);
|
||||||
|
draftNote.tags = [...existing];
|
||||||
|
draftNote.updatedAt = now();
|
||||||
|
renderDraftTags();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTagInput() {
|
||||||
|
const input = noteContainer?.querySelector("#o1n-tag-input");
|
||||||
|
if (!input) return;
|
||||||
|
if (addTagsFromText(input.value)) input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTagInputKeydown(e) {
|
||||||
|
if (e.key !== "Enter" && e.key !== "," && e.key !== ",") return;
|
||||||
|
e.preventDefault();
|
||||||
|
commitTagInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraftTag(tag) {
|
||||||
|
if (!draftNote) return;
|
||||||
|
draftNote.tags = (draftNote.tags || []).filter(t => t !== tag);
|
||||||
|
draftNote.updatedAt = now();
|
||||||
|
renderDraftTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDraftTags() {
|
||||||
|
const row = noteContainer?.querySelector(".o1n-tag-row");
|
||||||
|
if (!row || !draftNote) return;
|
||||||
|
row.innerHTML = `
|
||||||
|
${(draftNote.tags || []).map(tag => `<span class="o1n-tag-token">${escapeHtml(tag)} <span class="o1n-tag-remove" data-tag="${escapeHtml(tag)}">×</span></span>`).join("")}
|
||||||
|
<input class="o1n-tag-input" id="o1n-tag-input" placeholder="+ 添加标签">
|
||||||
|
`;
|
||||||
|
row.querySelectorAll(".o1n-tag-remove").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => removeDraftTag(btn.dataset.tag));
|
||||||
|
});
|
||||||
|
const input = row.querySelector("#o1n-tag-input");
|
||||||
|
input.addEventListener("keydown", handleTagInputKeydown);
|
||||||
|
input.addEventListener("blur", commitTagInput);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (!draftNote) return;
|
||||||
|
updateDraftFromEditor();
|
||||||
|
draftNote.tags = draftNote.tags?.length ? draftNote.tags : ["未分类"];
|
||||||
|
if (editingNoteId) {
|
||||||
|
const idx = notes.findIndex(n => n.id === editingNoteId);
|
||||||
|
if (idx >= 0) notes[idx] = cloneNote(draftNote);
|
||||||
|
} else {
|
||||||
|
draftNote.id = genId();
|
||||||
|
draftNote.createdAt = now();
|
||||||
|
draftNote.updatedAt = now();
|
||||||
|
notes.unshift(cloneNote(draftNote));
|
||||||
|
editingNoteId = draftNote.id;
|
||||||
|
}
|
||||||
|
saveNotes();
|
||||||
|
renderTagFilters();
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteEditingNote() {
|
||||||
|
if (!editingNoteId) {
|
||||||
|
closeEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDelete = true;
|
||||||
|
const box = noteContainer?.querySelector("#o1n-delete-confirm");
|
||||||
|
box?.classList.add("show");
|
||||||
|
setStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDeleteConfirm() {
|
||||||
|
pendingDelete = false;
|
||||||
|
noteContainer?.querySelector("#o1n-delete-confirm")?.classList.remove("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEditingNote() {
|
||||||
|
if (!editingNoteId) {
|
||||||
|
closeEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = notes.find(n => n.id === editingNoteId);
|
||||||
|
if (!note) return;
|
||||||
|
notes = notes.filter(n => n.id !== editingNoteId);
|
||||||
|
saveNotes();
|
||||||
|
renderTagFilters();
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDraftContent() {
|
||||||
|
updateDraftFromEditor();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(draftNote?.content || "");
|
||||||
|
setStatus("已复制");
|
||||||
|
} catch {
|
||||||
|
setStatus("复制失败,请手动选择内容");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDraftContent() {
|
||||||
|
updateDraftFromEditor();
|
||||||
|
const text = draftNote?.content || "";
|
||||||
|
if (!text.trim()) {
|
||||||
|
setStatus("当前笔记内容为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (insertIntoFocusedInput(text) || insertIntoSelectedNode(text)) {
|
||||||
|
setStatus("已插入");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
setStatus("未找到可插入位置,已复制内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
const el = noteContainer?.querySelector("#o1n-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = message;
|
||||||
|
clearTimeout(setStatus._timer);
|
||||||
|
setStatus._timer = setTimeout(() => {
|
||||||
|
if (el.textContent === message) el.textContent = "";
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPanelStatus(message) {
|
||||||
|
const el = noteContainer?.querySelector("#o1n-panel-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = message;
|
||||||
|
clearTimeout(setPanelStatus._timer);
|
||||||
|
setPanelStatus._timer = setTimeout(() => {
|
||||||
|
if (el.textContent === message) el.textContent = "";
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertIntoFocusedInput(text) {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el || noteContainer.contains(el)) return false;
|
||||||
|
if (!(el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement)) return false;
|
||||||
|
const start = el.selectionStart ?? el.value.length;
|
||||||
|
const end = el.selectionEnd ?? el.value.length;
|
||||||
|
const before = el.value.slice(0, start);
|
||||||
|
const after = el.value.slice(end);
|
||||||
|
const spacer = before && !before.endsWith("\n") ? "\n" : "";
|
||||||
|
el.value = before + spacer + text + after;
|
||||||
|
const pos = (before + spacer + text).length;
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSelectedNode() {
|
||||||
|
const selectedNodes = app.canvas?.selected_nodes;
|
||||||
|
if (selectedNodes) {
|
||||||
|
const values = Array.isArray(selectedNodes) ? selectedNodes : Object.values(selectedNodes);
|
||||||
|
if (values.length) return values[0];
|
||||||
|
}
|
||||||
|
return app.canvas?.selected_node || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromptWidget(widget) {
|
||||||
|
const name = String(widget?.name || "").toLowerCase();
|
||||||
|
const value = widget?.value;
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
return (
|
||||||
|
name.includes("prompt") ||
|
||||||
|
name.includes("提示词") ||
|
||||||
|
name.includes("正向") ||
|
||||||
|
name === "text" ||
|
||||||
|
name === "文本"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertIntoSelectedNode(text) {
|
||||||
|
const node = findSelectedNode();
|
||||||
|
if (!node?.widgets?.length) return false;
|
||||||
|
const widget = node.widgets.find(isPromptWidget) || node.widgets.find(w => typeof w.value === "string");
|
||||||
|
if (!widget) return false;
|
||||||
|
const current = widget.value || "";
|
||||||
|
const spacer = current && !current.endsWith("\n") ? "\n" : "";
|
||||||
|
widget.value = current + spacer + text;
|
||||||
|
widget.callback?.(widget.value, app.canvas, node, widget);
|
||||||
|
app.graph?.setDirtyCanvas?.(true, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptWidgetsFromSelectedNode() {
|
||||||
|
const node = findSelectedNode();
|
||||||
|
if (!node?.widgets?.length) return [];
|
||||||
|
return node.widgets.filter(w => typeof w.value === "string" && String(w.value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFromCurrentNode() {
|
||||||
|
const widgets = getPromptWidgetsFromSelectedNode();
|
||||||
|
if (!widgets.length) {
|
||||||
|
setStatus("当前没有选中包含文本的节点");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = widgets.find(isPromptWidget) || widgets[0];
|
||||||
|
const node = findSelectedNode();
|
||||||
|
draftNote = makeNote({
|
||||||
|
title: `${node?.title || node?.type || "节点"}:${preferred.name || "提示词"}`,
|
||||||
|
tags: [node?.title || node?.type || "节点"],
|
||||||
|
content: preferred.value,
|
||||||
|
});
|
||||||
|
editingNoteId = null;
|
||||||
|
renderEditor();
|
||||||
|
setStatus("已读取当前节点内容,保存后写入笔记");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportNotes() {
|
||||||
|
const blob = new Blob([JSON.stringify(notes, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `o1key-notes-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importNotes(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(await file.text());
|
||||||
|
const list = Array.isArray(imported) ? imported : imported.notes;
|
||||||
|
if (!Array.isArray(list)) throw new Error("Invalid notes file");
|
||||||
|
const existing = new Set(notes.map(n => n.id));
|
||||||
|
const normalized = list.map(makeNote).map(n => {
|
||||||
|
if (existing.has(n.id)) n.id = genId();
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
notes = [...normalized, ...notes];
|
||||||
|
saveNotes();
|
||||||
|
renderTagFilters();
|
||||||
|
renderList();
|
||||||
|
setPanelStatus(`已导入 ${normalized.length} 条笔记`);
|
||||||
|
} catch {
|
||||||
|
setPanelStatus("导入失败,请确认 JSON 格式");
|
||||||
|
}
|
||||||
|
}
|
||||||
+319
-5
@@ -25,6 +25,7 @@ const STYLES = `
|
|||||||
.pb-toolbar .pb-sep { width:1px; height:24px; background:#555; margin:0 4px; }
|
.pb-toolbar .pb-sep { width:1px; height:24px; background:#555; margin:0 4px; }
|
||||||
.pb-toolbar input[type=color] { width:32px; height:28px; border:none; padding:0; cursor:pointer; border-radius:4px; }
|
.pb-toolbar input[type=color] { width:32px; height:28px; border:none; padding:0; cursor:pointer; border-radius:4px; }
|
||||||
.pb-toolbar input[type=range] { width:80px; accent-color:#0066ff; }
|
.pb-toolbar input[type=range] { width:80px; accent-color:#0066ff; }
|
||||||
|
.pb-toolbar input.pb-mosaic-size { width:90px; }
|
||||||
.pb-toolbar .pb-label { color:#aaa; font-size:12px; }
|
.pb-toolbar .pb-label { color:#aaa; font-size:12px; }
|
||||||
.pb-canvas-wrap { border:2px solid #444; border-radius:4px; overflow:hidden; }
|
.pb-canvas-wrap { border:2px solid #444; border-radius:4px; overflow:hidden; }
|
||||||
.pb-actions { display:flex; gap:10px; margin-top:10px; }
|
.pb-actions { display:flex; gap:10px; margin-top:10px; }
|
||||||
@@ -41,6 +42,64 @@ function injectStyles() {
|
|||||||
document.head.appendChild(el);
|
document.head.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PB_EXPORT_PROPS = ["pbTool"];
|
||||||
|
|
||||||
|
function clampPointer(canvas, pointer) {
|
||||||
|
return {
|
||||||
|
x: clamp(pointer.x, 0, canvas.width),
|
||||||
|
y: clamp(pointer.y, 0, canvas.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCircleCursor(size) {
|
||||||
|
const cursorSize = clamp(Math.round(size), 18, 80);
|
||||||
|
const center = cursorSize / 2;
|
||||||
|
const hotspot = Math.round(center);
|
||||||
|
const radius = Math.max(3, center - 2);
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${cursorSize}" height="${cursorSize}" viewBox="0 0 ${cursorSize} ${cursorSize}">
|
||||||
|
<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke="black" stroke-width="3"/>
|
||||||
|
<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke="white" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
return `url("data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}") ${hotspot} ${hotspot}, crosshair`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMosaicCursor(canvas, state) {
|
||||||
|
const cursor = makeCircleCursor(state.mosaicSize);
|
||||||
|
canvas.defaultCursor = cursor;
|
||||||
|
canvas.hoverCursor = cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureMosaicObject(obj) {
|
||||||
|
obj.set({
|
||||||
|
selectable: false,
|
||||||
|
hasControls: false,
|
||||||
|
hasBorders: false,
|
||||||
|
lockMovementX: true,
|
||||||
|
lockMovementY: true,
|
||||||
|
lockScalingX: true,
|
||||||
|
lockScalingY: true,
|
||||||
|
lockRotation: true,
|
||||||
|
perPixelTargetFind: true,
|
||||||
|
objectCaching: false,
|
||||||
|
});
|
||||||
|
obj.pbTool = "mosaic";
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMosaicObjects(canvas) {
|
||||||
|
canvas.getObjects().forEach((obj) => {
|
||||||
|
if (obj.pbTool === "mosaic" || obj.type === "image") {
|
||||||
|
configureMosaicObject(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Get current image URL from node ---
|
// --- Get current image URL from node ---
|
||||||
function getImageUrl(node) {
|
function getImageUrl(node) {
|
||||||
if (node.imgs && node.imgs.length > 0) {
|
if (node.imgs && node.imgs.length > 0) {
|
||||||
@@ -71,7 +130,7 @@ class HistoryManager {
|
|||||||
if (this.locked) return;
|
if (this.locked) return;
|
||||||
this.index++;
|
this.index++;
|
||||||
this.stack.length = this.index;
|
this.stack.length = this.index;
|
||||||
this.stack.push(this.canvas.toJSON());
|
this.stack.push(this.canvas.toJSON(PB_EXPORT_PROPS));
|
||||||
}
|
}
|
||||||
undo() {
|
undo() {
|
||||||
if (this.index <= 0) return;
|
if (this.index <= 0) return;
|
||||||
@@ -86,6 +145,7 @@ class HistoryManager {
|
|||||||
_restore() {
|
_restore() {
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
this.canvas.loadFromJSON(this.stack[this.index], () => {
|
this.canvas.loadFromJSON(this.stack[this.index], () => {
|
||||||
|
normalizeMosaicObjects(this.canvas);
|
||||||
this.canvas.renderAll();
|
this.canvas.renderAll();
|
||||||
this.locked = false;
|
this.locked = false;
|
||||||
});
|
});
|
||||||
@@ -97,7 +157,7 @@ function setupShapeDrawing(canvas, state) {
|
|||||||
let startX, startY, shape;
|
let startX, startY, shape;
|
||||||
|
|
||||||
canvas.on("mouse:down", (opt) => {
|
canvas.on("mouse:down", (opt) => {
|
||||||
if (state.tool === "select" || state.tool === "brush") return;
|
if (state.tool === "select" || state.tool === "brush" || state.tool === "eraser" || state.tool === "mosaic") return;
|
||||||
const ptr = canvas.getPointer(opt.e);
|
const ptr = canvas.getPointer(opt.e);
|
||||||
startX = ptr.x;
|
startX = ptr.x;
|
||||||
startY = ptr.y;
|
startY = ptr.y;
|
||||||
@@ -132,12 +192,247 @@ function setupShapeDrawing(canvas, state) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
canvas.on("mouse:up", () => {
|
canvas.on("mouse:up", () => {
|
||||||
if (!state.drawing) return;
|
if (!state.drawing || !shape) return;
|
||||||
state.drawing = false;
|
state.drawing = false;
|
||||||
shape = null;
|
shape = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mosaic brush handler ---
|
||||||
|
function createMosaicSource(sourceImg, canvas, blockSize) {
|
||||||
|
const scaleX = canvas.width / sourceImg.width;
|
||||||
|
const scaleY = canvas.height / sourceImg.height;
|
||||||
|
const sourceBlockSize = Math.max(1, Math.round(blockSize / Math.min(scaleX, scaleY)));
|
||||||
|
const smallW = Math.max(1, Math.ceil(sourceImg.width / sourceBlockSize));
|
||||||
|
const smallH = Math.max(1, Math.ceil(sourceImg.height / sourceBlockSize));
|
||||||
|
|
||||||
|
const smallCanvas = document.createElement("canvas");
|
||||||
|
smallCanvas.width = smallW;
|
||||||
|
smallCanvas.height = smallH;
|
||||||
|
const smallCtx = smallCanvas.getContext("2d");
|
||||||
|
smallCtx.imageSmoothingEnabled = true;
|
||||||
|
smallCtx.drawImage(sourceImg, 0, 0, sourceImg.width, sourceImg.height, 0, 0, smallW, smallH);
|
||||||
|
|
||||||
|
const pixelCanvas = document.createElement("canvas");
|
||||||
|
pixelCanvas.width = sourceImg.width;
|
||||||
|
pixelCanvas.height = sourceImg.height;
|
||||||
|
const pixelCtx = pixelCanvas.getContext("2d");
|
||||||
|
pixelCtx.imageSmoothingEnabled = false;
|
||||||
|
pixelCtx.drawImage(smallCanvas, 0, 0, smallW, smallH, 0, 0, sourceImg.width, sourceImg.height);
|
||||||
|
|
||||||
|
return pixelCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMosaicBrushBounds(sourceImg, canvas, centerX, centerY, size) {
|
||||||
|
const scaleX = canvas.width / sourceImg.width;
|
||||||
|
const scaleY = canvas.height / sourceImg.height;
|
||||||
|
const sourceX = centerX / scaleX;
|
||||||
|
const sourceY = centerY / scaleY;
|
||||||
|
const radiusX = Math.max(1, (size / 2) / scaleX);
|
||||||
|
const radiusY = Math.max(1, (size / 2) / scaleY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
centerX: sourceX,
|
||||||
|
centerY: sourceY,
|
||||||
|
radius: Math.max(radiusX, radiusY),
|
||||||
|
left: clamp(Math.floor(sourceX - radiusX), 0, sourceImg.width),
|
||||||
|
top: clamp(Math.floor(sourceY - radiusY), 0, sourceImg.height),
|
||||||
|
right: clamp(Math.ceil(sourceX + radiusX), 0, sourceImg.width),
|
||||||
|
bottom: clamp(Math.ceil(sourceY + radiusY), 0, sourceImg.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandMosaicBounds(bounds, brushBounds) {
|
||||||
|
if (!bounds.left && bounds.left !== 0) {
|
||||||
|
bounds.left = brushBounds.left;
|
||||||
|
bounds.top = brushBounds.top;
|
||||||
|
bounds.right = brushBounds.right;
|
||||||
|
bounds.bottom = brushBounds.bottom;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bounds.left = Math.min(bounds.left, brushBounds.left);
|
||||||
|
bounds.top = Math.min(bounds.top, brushBounds.top);
|
||||||
|
bounds.right = Math.max(bounds.right, brushBounds.right);
|
||||||
|
bounds.bottom = Math.max(bounds.bottom, brushBounds.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, bounds, point) {
|
||||||
|
const brushBounds = getMosaicBrushBounds(sourceImg, canvas, point.x, point.y, point.size);
|
||||||
|
if (brushBounds.right <= brushBounds.left || brushBounds.bottom <= brushBounds.top) return false;
|
||||||
|
|
||||||
|
strokeCtx.save();
|
||||||
|
strokeCtx.beginPath();
|
||||||
|
strokeCtx.arc(brushBounds.centerX, brushBounds.centerY, brushBounds.radius, 0, Math.PI * 2);
|
||||||
|
strokeCtx.clip();
|
||||||
|
strokeCtx.drawImage(
|
||||||
|
mosaicSource,
|
||||||
|
brushBounds.left,
|
||||||
|
brushBounds.top,
|
||||||
|
brushBounds.right - brushBounds.left,
|
||||||
|
brushBounds.bottom - brushBounds.top,
|
||||||
|
brushBounds.left,
|
||||||
|
brushBounds.top,
|
||||||
|
brushBounds.right - brushBounds.left,
|
||||||
|
brushBounds.bottom - brushBounds.top
|
||||||
|
);
|
||||||
|
strokeCtx.restore();
|
||||||
|
|
||||||
|
expandMosaicBounds(bounds, brushBounds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMosaicLine(sourceImg, canvas, mosaicSource, strokeCtx, bounds, from, to, size) {
|
||||||
|
const dx = to.x - from.x;
|
||||||
|
const dy = to.y - from.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const step = Math.max(2, size * 0.25);
|
||||||
|
const steps = Math.max(1, Math.ceil(distance / step));
|
||||||
|
let painted = false;
|
||||||
|
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
painted = paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, bounds, {
|
||||||
|
x: from.x + dx * t,
|
||||||
|
y: from.y + dy * t,
|
||||||
|
size,
|
||||||
|
}) || painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMosaicStrokeObject(strokeCanvas, bounds, canvas, sourceImg) {
|
||||||
|
const width = bounds.right - bounds.left;
|
||||||
|
const height = bounds.bottom - bounds.top;
|
||||||
|
if (width < 1 || height < 1) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const cropCanvas = document.createElement("canvas");
|
||||||
|
cropCanvas.width = width;
|
||||||
|
cropCanvas.height = height;
|
||||||
|
cropCanvas.getContext("2d").drawImage(
|
||||||
|
strokeCanvas,
|
||||||
|
bounds.left,
|
||||||
|
bounds.top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaleX = canvas.width / sourceImg.width;
|
||||||
|
const scaleY = canvas.height / sourceImg.height;
|
||||||
|
const dataUrl = cropCanvas.toDataURL("image/png");
|
||||||
|
return new Promise(resolve => {
|
||||||
|
fabric.Image.fromURL(dataUrl, (imgObj) => {
|
||||||
|
configureMosaicObject(imgObj).set({
|
||||||
|
left: bounds.left * scaleX,
|
||||||
|
top: bounds.top * scaleY,
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
});
|
||||||
|
resolve(imgObj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMosaicDrawing(canvas, state, sourceImg, history) {
|
||||||
|
let lastPoint, mosaicSource, strokeCanvas, strokeCtx, strokePreview, strokeBounds, strokePainted;
|
||||||
|
|
||||||
|
const refreshPreview = () => {
|
||||||
|
if (!strokePreview) return;
|
||||||
|
strokePreview.dirty = true;
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.on("mouse:down", (opt) => {
|
||||||
|
if (state.tool !== "mosaic") return;
|
||||||
|
const ptr = clampPointer(canvas, canvas.getPointer(opt.e));
|
||||||
|
lastPoint = ptr;
|
||||||
|
state.drawing = true;
|
||||||
|
strokePainted = false;
|
||||||
|
strokeBounds = {};
|
||||||
|
mosaicSource = createMosaicSource(sourceImg, canvas, state.mosaicSize);
|
||||||
|
|
||||||
|
strokeCanvas = document.createElement("canvas");
|
||||||
|
strokeCanvas.width = sourceImg.width;
|
||||||
|
strokeCanvas.height = sourceImg.height;
|
||||||
|
strokeCtx = strokeCanvas.getContext("2d");
|
||||||
|
strokeCtx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
strokePreview = new fabric.Image(strokeCanvas, {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
scaleX: canvas.width / sourceImg.width,
|
||||||
|
scaleY: canvas.height / sourceImg.height,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
excludeFromExport: true,
|
||||||
|
objectCaching: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
history.locked = true;
|
||||||
|
canvas.add(strokePreview);
|
||||||
|
history.locked = false;
|
||||||
|
|
||||||
|
strokePainted = paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, strokeBounds, {
|
||||||
|
x: ptr.x,
|
||||||
|
y: ptr.y,
|
||||||
|
size: state.mosaicSize,
|
||||||
|
}) || strokePainted;
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on("mouse:move", (opt) => {
|
||||||
|
if (state.tool !== "mosaic" || !state.drawing || !strokeCtx || !lastPoint) return;
|
||||||
|
const ptr = clampPointer(canvas, canvas.getPointer(opt.e));
|
||||||
|
strokePainted = paintMosaicLine(
|
||||||
|
sourceImg,
|
||||||
|
canvas,
|
||||||
|
mosaicSource,
|
||||||
|
strokeCtx,
|
||||||
|
strokeBounds,
|
||||||
|
lastPoint,
|
||||||
|
ptr,
|
||||||
|
state.mosaicSize
|
||||||
|
) || strokePainted;
|
||||||
|
lastPoint = ptr;
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.on("mouse:up", async () => {
|
||||||
|
if (!state.drawing || !strokePreview) return;
|
||||||
|
|
||||||
|
history.locked = true;
|
||||||
|
canvas.remove(strokePreview);
|
||||||
|
history.locked = false;
|
||||||
|
state.drawing = false;
|
||||||
|
lastPoint = null;
|
||||||
|
|
||||||
|
if (!strokePainted) {
|
||||||
|
strokeCanvas = null;
|
||||||
|
strokeCtx = null;
|
||||||
|
strokePreview = null;
|
||||||
|
mosaicSource = null;
|
||||||
|
canvas.renderAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mosaicObj = await createMosaicStrokeObject(strokeCanvas, strokeBounds, canvas, sourceImg);
|
||||||
|
mosaicSource = null;
|
||||||
|
strokeCanvas = null;
|
||||||
|
strokeCtx = null;
|
||||||
|
strokePreview = null;
|
||||||
|
|
||||||
|
if (mosaicObj) {
|
||||||
|
canvas.add(mosaicObj);
|
||||||
|
canvas.discardActiveObject();
|
||||||
|
}
|
||||||
|
canvas.renderAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Open Paint Modal ---
|
// --- Open Paint Modal ---
|
||||||
async function openPaintModal(node) {
|
async function openPaintModal(node) {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
@@ -192,7 +487,7 @@ async function openPaintModal(node) {
|
|||||||
const cw = Math.round(img.width * scale);
|
const cw = Math.round(img.width * scale);
|
||||||
const ch = Math.round(img.height * scale);
|
const ch = Math.round(img.height * scale);
|
||||||
|
|
||||||
const state = { tool: "brush", color: "#ff0000", width: 4, drawing: false };
|
const state = { tool: "brush", color: "#ff0000", width: 4, mosaicSize: 14, drawing: false };
|
||||||
|
|
||||||
const toolbar = buildToolbar(state);
|
const toolbar = buildToolbar(state);
|
||||||
overlay.appendChild(toolbar);
|
overlay.appendChild(toolbar);
|
||||||
@@ -222,6 +517,7 @@ async function openPaintModal(node) {
|
|||||||
if (savedState) {
|
if (savedState) {
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
canvas.loadFromJSON(savedState, () => {
|
canvas.loadFromJSON(savedState, () => {
|
||||||
|
normalizeMosaicObjects(canvas);
|
||||||
// Re-apply background since loadFromJSON may clear it
|
// Re-apply background since loadFromJSON may clear it
|
||||||
canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, {
|
canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, {
|
||||||
scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous"
|
scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous"
|
||||||
@@ -238,6 +534,7 @@ async function openPaintModal(node) {
|
|||||||
|
|
||||||
// Shape drawing
|
// Shape drawing
|
||||||
setupShapeDrawing(canvas, state);
|
setupShapeDrawing(canvas, state);
|
||||||
|
setupMosaicDrawing(canvas, state, img, history);
|
||||||
wireToolbar(toolbar, canvas, state, history);
|
wireToolbar(toolbar, canvas, state, history);
|
||||||
|
|
||||||
// Actions buttons
|
// Actions buttons
|
||||||
@@ -265,7 +562,7 @@ async function openPaintModal(node) {
|
|||||||
// Confirm - save painted image and store canvas state for re-editing
|
// Confirm - save painted image and store canvas state for re-editing
|
||||||
actions.querySelector(".pb-confirm").onclick = async () => {
|
actions.querySelector(".pb-confirm").onclick = async () => {
|
||||||
// Save canvas objects (without background) for future re-editing
|
// Save canvas objects (without background) for future re-editing
|
||||||
node.properties.paintBrushCanvas = canvas.toJSON();
|
node.properties.paintBrushCanvas = canvas.toJSON(PB_EXPORT_PROPS);
|
||||||
node.graph?.change?.();
|
node.graph?.change?.();
|
||||||
await savePaintedImage(canvas, node, img.width, img.height);
|
await savePaintedImage(canvas, node, img.width, img.height);
|
||||||
close();
|
close();
|
||||||
@@ -326,12 +623,15 @@ function buildToolbar(state) {
|
|||||||
<button data-tool="rect">矩形</button>
|
<button data-tool="rect">矩形</button>
|
||||||
<button data-tool="circle">圆形</button>
|
<button data-tool="circle">圆形</button>
|
||||||
<button data-tool="line">直线</button>
|
<button data-tool="line">直线</button>
|
||||||
|
<button data-tool="mosaic">马赛克</button>
|
||||||
<button data-tool="eraser">橡皮擦</button>
|
<button data-tool="eraser">橡皮擦</button>
|
||||||
<span class="pb-sep"></span>
|
<span class="pb-sep"></span>
|
||||||
<span class="pb-label">颜色</span>
|
<span class="pb-label">颜色</span>
|
||||||
<input type="color" class="pb-color" value="${state.color}">
|
<input type="color" class="pb-color" value="${state.color}">
|
||||||
<span class="pb-label">线宽</span>
|
<span class="pb-label">线宽</span>
|
||||||
<input type="range" class="pb-width" min="1" max="40" value="${state.width}">
|
<input type="range" class="pb-width" min="1" max="40" value="${state.width}">
|
||||||
|
<span class="pb-label">块大小</span>
|
||||||
|
<input type="range" class="pb-mosaic-size" min="4" max="80" value="${state.mosaicSize}">
|
||||||
<span class="pb-sep"></span>
|
<span class="pb-sep"></span>
|
||||||
<button data-action="undo">撤回</button>
|
<button data-action="undo">撤回</button>
|
||||||
<button data-action="clear">清空</button>
|
<button data-action="clear">清空</button>
|
||||||
@@ -351,6 +651,8 @@ function wireToolbar(toolbar, canvas, state, history) {
|
|||||||
if (state.tool === "brush") {
|
if (state.tool === "brush") {
|
||||||
canvas.isDrawingMode = true;
|
canvas.isDrawingMode = true;
|
||||||
canvas.selection = false;
|
canvas.selection = false;
|
||||||
|
canvas.defaultCursor = "default";
|
||||||
|
canvas.hoverCursor = "move";
|
||||||
canvas.freeDrawingBrush.color = state.color;
|
canvas.freeDrawingBrush.color = state.color;
|
||||||
canvas.freeDrawingBrush.width = state.width;
|
canvas.freeDrawingBrush.width = state.width;
|
||||||
} else if (state.tool === "eraser") {
|
} else if (state.tool === "eraser") {
|
||||||
@@ -359,6 +661,11 @@ function wireToolbar(toolbar, canvas, state, history) {
|
|||||||
canvas.selection = true;
|
canvas.selection = true;
|
||||||
canvas.defaultCursor = "crosshair";
|
canvas.defaultCursor = "crosshair";
|
||||||
canvas.hoverCursor = "pointer";
|
canvas.hoverCursor = "pointer";
|
||||||
|
} else if (state.tool === "mosaic") {
|
||||||
|
canvas.isDrawingMode = false;
|
||||||
|
canvas.selection = false;
|
||||||
|
canvas.discardActiveObject();
|
||||||
|
applyMosaicCursor(canvas, state);
|
||||||
} else {
|
} else {
|
||||||
canvas.isDrawingMode = false;
|
canvas.isDrawingMode = false;
|
||||||
canvas.selection = false;
|
canvas.selection = false;
|
||||||
@@ -396,6 +703,13 @@ function wireToolbar(toolbar, canvas, state, history) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
toolbar.querySelector(".pb-mosaic-size").oninput = (e) => {
|
||||||
|
state.mosaicSize = parseInt(e.target.value);
|
||||||
|
if (state.tool === "mosaic") {
|
||||||
|
applyMosaicCursor(canvas, state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
toolbar.querySelector("[data-action=undo]").onclick = () => history.undo();
|
toolbar.querySelector("[data-action=undo]").onclick = () => history.undo();
|
||||||
toolbar.querySelector("[data-action=clear]").onclick = () => {
|
toolbar.querySelector("[data-action=clear]").onclick = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user