diff --git a/README.md b/README.md index 02757bc..7b773ce 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,27 @@ It bundles image generation, text-to-video, image-to-video, multi-image video, a - 清晰度为 `720p / 1080p`,时长为 `auto / 3–30` 秒;`auto` 不发送时长参数,由接口按提示词和素材决定。 - 文生支持 `21:9 / 16:9 / 9:16 / 1:1`;全能参考另支持 `adaptive`;图生按输入帧确定画幅。 - 负向要求、镜头和声音写入提示词,不提供旧版 `cfg_scale`、`negative_prompt`、`camera_control`、`sound` 控件。 -- 图生及全能节点保留原生提示词文本框,下方同步显示彩色引用预览;点击彩色按钮可在光标处插入引用。预览不是文本框内语法高亮,不会改变实际提交的提示词;颜色仅区分素材类型,不代表素材已经连接或验证通过。 +- 图生及全能节点保留原生提示词文本框,下方同步显示彩色引用预览;点击彩色按钮可在光标处插入引用。预览不是文本框内语法高亮;旧版关键帧别名会在提交时转换为统一 image_N;颜色仅区分素材类型,不代表素材已经连接或验证通过。 - 全能节点直接接现有 `Element List Builder`、`Custom Voice List Builder` 输出;主体及音色仍须在对应官方账号下可用。无需新建管理或素材构建节点。 - 输出仍为 `url / video_id`,可连接现有 `Preview Video`。任务提交后记录任务 ID;查询失败时保留 ID,便于先查询任务再决定是否重新生成。 | 输入 | 提示词引用 | |---|---| -| `reference_images` 图片批次 | `@image_1`、`@image_2`… | -| 动态 `image_1`、`image_2`…(每口一张) | 与接口名称一致,例如 `@image_2` | -| `first_frame` 单张首帧 | `@first_frame` | -| `middle_frames` 中间帧批次 | `@middle_frame_1`、`@middle_frame_2`… | -| `last_frame` 单张尾帧 | `@last_frame` | +| `first_frame` 单张首帧 | 有输入时从 `@image_1` 开始 | +| `middle_frames` 中间帧批次 | 接在首帧之后,每张占用一个 `image_N` | +| `last_frame` 单张尾帧 | 接在全部中间帧之后 | +| 动态 `image_1`、`image_2`…或旧 `reference_images` 批次 | 接在全部关键帧之后,查看接口旁实际引用号;接口名不等于最终 ID | | 视频:`base_video` 或 `base_video_url`,然后 `feature_video`,然后 `feature_video_urls` 每行一个 URL | 按上述顺序编号 `@video_1`、`@video_2`… | | `element_list` 主体列表 | `@element_1`、`@element_2`… | | `custom_voice_list` 音色列表 | `@voice_1`、`@voice_2`… | -参考图与关键帧编号独立。待编辑视频只能选择 `base_video` 或 `base_video_url` 之一;VIDEO 输入沿用现有上传服务,图片通过 base64 发送。素材数量、视频规格等服务端限制以所附 4.0 文档为准。 +所有图片统一按首帧→中间帧批次→尾帧→参考图顺序连续编号,未连接的图片不占号。例如首中尾各一张,再接两张参考图,就是 image_1 到 image_5。待编辑视频只能选择 `base_video` 或 `base_video_url` 之一;VIDEO 输入沿用现有上传服务,图片通过 base64 发送。素材数量、视频规格等服务端限制以所附 4.0 文档为准。 -全能节点现使用与 Seedance 相同的动态连接交互:接上 `image_1` 后自动出现下一个图片口,接上 `feature_video_1` 后自动出现下一个特征视频口。连接口旁显示真实 `@image_N / @video_N` 引用;移除中间图片或特征视频时保留其余接口编号。接入或移除 `base_video`(包括 URL)会改变特征视频的引用偏移,界面会同步提示,请对应检查提示词。`base_video` 存在时固定为 `@video_1`,第一个特征视频为 `@video_2`;不存在时第一个特征视频为 `@video_1`。 +全能节点现使用与 Seedance 相同的动态连接交互:接上 `image_1` 后自动出现下一个图片口,接上 `feature_video_1` 后自动出现下一个特征视频口。连接口旁显示真实 `@image_N / @video_N` 引用;移除中间图片时保留接口名称,但实际图片 ID 会重新连续分配;特征视频仍保留自身接口编号。增删关键帧、改变批次张数或参考图后,请按新标签检查提示词。接入或移除 `base_video`(包括 URL)会改变特征视频的引用偏移,界面会同步提示,请对应检查提示词。`base_video` 存在时固定为 `@video_1`,第一个特征视频为 `@video_2`;不存在时第一个特征视频为 `@video_1`。 -旧工作流已经连接的 `reference_images` / `feature_video` 输入仍可使用,新节点不显示这些旧输入。旧图片批次与新动态图片不能同时使用。运行前校验总素材不超过 15,图片(含首中尾帧)不超过 10、视频不超过 5、主体不超过 7、音色不超过 3;视频角色主体占用的视频数量等依赖资产信息的限制仍由服务端校验。前端无法读取上游图片批次及主体/音色列表的实际长度,总数以执行前校验为准。 +旧工作流已经连接的 `reference_images` / `feature_video` 输入仍可使用,新节点不显示这些旧输入。旧图片批次与新动态图片不能同时使用。运行前校验总素材不超过 15,图片(含首中尾帧)不超过 10、视频不超过 5、主体不超过 7、音色不超过 3;视频角色主体占用的视频数量等依赖资产信息的限制仍由服务端校验。前端通过本地图片解码和 Image Batch 连接推导张数(含动图);浏览器不支持或无法读取时保持未知。其他不确定批次及其后续图片显示“待执行确定”,不猜测引用号。主体/音色列表的实际长度及素材总数以执行前校验为准。 + +兼容旧提示词:当提示词包含已连接关键帧的 `@first_frame` / `@middle_frame_N` / `@last_frame` 时,按旧命名空间一次性转换这些别名及旧参考图 `@image_N`,不会连锁替换。新提示词只使用统一 `@image_N`。如果旧工作流同时有关键帧和参考图,或旧参考图接口编号存在空洞,却只写了 `@image_N` 而没有任何旧关键帧别名,无法自动判断其含义,需手动核对并更新;不要混用新编号和旧别名。 导入示例:[文生](examples/kling4_text2video.json)、[图生](examples/kling4_image2video.json)、[全能参考](examples/kling4_omnivideo.json)。后两个示例需要在 Load Image 中选择本地图片。离线测试及本地 UI 验证不等同于真实生成成功,实际可用性取决于账号权限与官方服务。 diff --git a/examples/README.md b/examples/README.md index 7064a30..3248ccb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,7 +22,7 @@ This directory contains importable ComfyUI workflow JSON examples and small Pyth 4.0 生成节点固定使用官方配置,不受 `provider_default` 影响。**主体配音范例中的创建节点会遵循 `provider_default`,运行前请将其设为 `official`,并配置同一官方账号及素材上传服务。** 在两个 `Load Image` 中上传同一人物的正面照和另一角度参考照,在 `Load Audio` 中上传单人说话音频,修改主体名称、描述及音色名称后运行。也可在 `Load Audio` 中选择支持的视频文件,提取其音轨作为音色参考。工作流会创建主体和音色,经列表节点自动接入全能生成节点,无需手填任务 ID。 -图片、视频在对应 Load 节点上传或选择即可。动作参考视频连接 `feature_video`,待编辑原视频连接 `base_video`;两个例子都只有一个视频,因此均引用 `@video_1`。关键帧使用独立引用名,不占用参考图的 `@image_1` 编号。示例已在本地 ComfyUI 中完成导入及 API 参数导出检查,未调用付费生成接口。 +图片、视频在对应 Load 节点上传或选择即可。动作参考视频连接 `feature_video`,待编辑原视频连接 `base_video`;两个例子都只有一个视频,因此均引用 `@video_1`。所有图片共用 image_N 编号:首帧→中间帧→尾帧→普通参考图。三关键帧范例依次为 @image_1、@image_2、@image_3,新增参考图会从 @image_4 起。示例已在本地 ComfyUI 中完成导入及 API 参数导出检查,未调用付费生成接口。 全能范例现改用动态 `image_1`、`feature_video_1` 接口,接满一项自动出现下一项。多参考范例包含 `base_video = @video_1`、`feature_video_1 = @video_2`、`feature_video_2 = @video_3`;删除原视频后,需按界面新标签修改提示词中的视频引用。占位图片和视频未替换时,ComfyUI 会提示文件不存在,这是示例的待填写项。 diff --git a/examples/kling4_image2video.json b/examples/kling4_image2video.json index a1370d1..7b8dcda 100644 --- a/examples/kling4_image2video.json +++ b/examples/kling4_image2video.json @@ -116,10 +116,11 @@ ], "title": "02 单图动起来", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 Image2Video" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 Image2Video", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ - "以 @first_frame 为首帧,人物自然转头看向镜头,然后微笑。镜头缓慢推进,保持人物面部与服装一致。", + "以 @image_1 为首帧,人物自然转头看向镜头,然后微笑。镜头缓慢推进,保持人物面部与服装一致。", "1080p", "10", false, @@ -127,7 +128,7 @@ "" ], "widgets_values_named": { - "prompt": "以 @first_frame 为首帧,人物自然转头看向镜头,然后微笑。镜头缓慢推进,保持人物面部与服装一致。", + "prompt": "以 @image_1 为首帧,人物自然转头看向镜头,然后微笑。镜头缓慢推进,保持人物面部与服装一致。", "resolution": "1080p", "duration": "10", "watermark": false, @@ -189,10 +190,10 @@ "title": "使用说明(运行前阅读)", "properties": {}, "widgets_values": [ - "在首帧 Load Image 中上传自己的图片;@first_frame 对应该图片。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" + "在首帧 Load Image 中上传自己的图片;@image_1 对应该图片。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" ], "widgets_values_named": { - "text": "在首帧 Load Image 中上传自己的图片;@first_frame 对应该图片。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" + "text": "在首帧 Load Image 中上传自己的图片;@image_1 对应该图片。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" }, "color": "#432", "bgcolor": "#653" @@ -291,4 +292,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/kling4_keyframes.json b/examples/kling4_keyframes.json index 3c67d61..3c73dd0 100644 --- a/examples/kling4_keyframes.json +++ b/examples/kling4_keyframes.json @@ -116,10 +116,11 @@ ], "title": "07 首中尾帧控制", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 Image2Video" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 Image2Video", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ - "以 @first_frame 为开始画面,人物缓慢转身,经过 @middle_frame_1 的姿态,最后自然过渡到 @last_frame。保持人物、服装和环境连续,避免突然跳切。", + "以 @image_1 为开始画面,人物缓慢转身,经过 @image_2 的姿态,最后自然过渡到 @image_3。保持人物、服装和环境连续,避免突然跳切。", "1080p", "10", false, @@ -127,7 +128,7 @@ "" ], "widgets_values_named": { - "prompt": "以 @first_frame 为开始画面,人物缓慢转身,经过 @middle_frame_1 的姿态,最后自然过渡到 @last_frame。保持人物、服装和环境连续,避免突然跳切。", + "prompt": "以 @image_1 为开始画面,人物缓慢转身,经过 @image_2 的姿态,最后自然过渡到 @image_3。保持人物、服装和环境连续,避免突然跳切。", "resolution": "1080p", "duration": "10", "watermark": false, @@ -189,10 +190,10 @@ "title": "使用说明(运行前阅读)", "properties": {}, "widgets_values": [ - "分别上传开始、中间、结束三张图片。此例中间帧只有一张;多张时可使用 Image Batch 合并后接 middle_frames,依次引用 @middle_frame_1、@middle_frame_2。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" + "分别上传开始、中间、结束三张图片。此例中间帧只有一张;多张时可使用 Image Batch 合并后接 middle_frames,依次占用 @image_2、@image_3 等编号,尾帧随中间帧张数向后顺延。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" ], "widgets_values_named": { - "text": "分别上传开始、中间、结束三张图片。此例中间帧只有一张;多张时可使用 Image Batch 合并后接 middle_frames,依次引用 @middle_frame_1、@middle_frame_2。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" + "text": "分别上传开始、中间、结束三张图片。此例中间帧只有一张;多张时可使用 Image Batch 合并后接 middle_frames,依次占用 @image_2、@image_3 等编号,尾帧随中间帧张数向后顺延。\n\n生成节点固定使用官方 Kling 配置。运行会提交真实计费生成任务;先替换所有占位素材。彩色引用只辅助阅读,不代表素材已连接。" }, "color": "#432", "bgcolor": "#653" @@ -247,7 +248,7 @@ "links": null } ], - "title": "首帧 @first_frame", + "title": "首帧 @image_1", "properties": { "Node name for S&R": "LoadImage" }, @@ -310,7 +311,7 @@ "links": null } ], - "title": "中间帧 @middle_frame_1", + "title": "中间帧 @image_2", "properties": { "Node name for S&R": "LoadImage" }, @@ -373,7 +374,7 @@ "links": null } ], - "title": "尾帧 @last_frame", + "title": "尾帧 @image_3", "properties": { "Node name for S&R": "LoadImage" }, @@ -433,4 +434,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/kling4_motion_reference.json b/examples/kling4_motion_reference.json index fa21708..0ed870d 100644 --- a/examples/kling4_motion_reference.json +++ b/examples/kling4_motion_reference.json @@ -182,7 +182,8 @@ ], "title": "04 模特参考视频动作", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "使用 @image_1 中的人物形象与服装,参考 @video_1 中人物的转身动作和行走节奏。保持 @image_1 的面部、发型和服装细节,背景为简洁的摄影棚,不使用参考视频中的背景。", diff --git a/examples/kling4_multi_reference.json b/examples/kling4_multi_reference.json index b95b088..4bf5ef6 100644 --- a/examples/kling4_multi_reference.json +++ b/examples/kling4_multi_reference.json @@ -207,7 +207,8 @@ ], "title": "动态图片与视频 · 接口旁查看引用号", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "编辑 @video_1:人物外观参考 @image_1,服装参考 @image_2。动作节奏参考 @video_2,运镜方式参考 @video_3。保留原视频场景,确保人物与服装一致。", @@ -654,4 +655,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/kling4_omnivideo.json b/examples/kling4_omnivideo.json index 9751453..e2850fd 100644 --- a/examples/kling4_omnivideo.json +++ b/examples/kling4_omnivideo.json @@ -182,7 +182,8 @@ ], "title": "03 人物图片参考", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "参考 @image_1 的人物造型,人物缓缓走过花园,保持面部、发型与服装细节一致。自然日光,柔和镜头运动。", diff --git a/examples/kling4_subject_voice.json b/examples/kling4_subject_voice.json index 92d9439..1121ebb 100644 --- a/examples/kling4_subject_voice.json +++ b/examples/kling4_subject_voice.json @@ -825,7 +825,8 @@ ], "title": "06 主体与音色 · 生成视频", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "@element_1 站在花园里,面向镜头,用 @voice_1 的音色说:“欢迎来到我们的花园。”语气自然温柔,口型与台词同步。保持主体外观一致。", diff --git a/examples/kling4_video_edit.json b/examples/kling4_video_edit.json index 920ed5e..5251c6b 100644 --- a/examples/kling4_video_edit.json +++ b/examples/kling4_video_edit.json @@ -182,7 +182,8 @@ ], "title": "05 修改原视频服装", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "编辑 @video_1,将人物的服装替换为 @image_1 中的服装。保持原视频的人物面部、动作、镜头运动和背景不变,保留参考服装的颜色、纹理与版型。", diff --git a/examples/kling4_video_subject_voice.json b/examples/kling4_video_subject_voice.json index a22d892..61347af 100644 --- a/examples/kling4_video_subject_voice.json +++ b/examples/kling4_video_subject_voice.json @@ -571,7 +571,8 @@ ], "title": "05 视频主体与音色 · 生成视频", "properties": { - "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo" + "Node name for S&R": "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo", + "kling4UnifiedImageIds": 2 }, "widgets_values": [ "@element_1 站在花园里,面向镜头,用 @voice_1 的音色说:“欢迎来到我们的花园。”语气自然温柔,口型与台词同步。保持主体外观一致。", diff --git a/py/nodes_v4.py b/py/nodes_v4.py index a3c2bfe..68400e9 100644 --- a/py/nodes_v4.py +++ b/py/nodes_v4.py @@ -26,9 +26,9 @@ def _common_inputs(): def _frame_inputs(): return { - "first_frame": ("IMAGE", {"tooltip": "单张首帧,引用 @first_frame"}), - "middle_frames": ("IMAGE", {"tooltip": "中间帧批次,引用 @middle_frame_1、@middle_frame_2…"}), - "last_frame": ("IMAGE", {"tooltip": "单张尾帧,引用 @last_frame"}), + "first_frame": ("IMAGE", {"tooltip": "单张首帧,所有图片按首帧→中间帧→尾帧→参考图统一编号。"}), + "middle_frames": ("IMAGE", {"tooltip": "中间帧批次,每张占用一个 image_N 编号;接在首帧之后。"}), + "last_frame": ("IMAGE", {"tooltip": "单张尾帧,接在所有中间帧之后编号。"}), } @@ -54,6 +54,24 @@ def _video_url(value): return value +def _unify_image_ids(prompt, contents): + aliases = {} + result = [] + for item in contents: + if item["type"] in {"first_frame", "middle_frame", "last_frame", "refer_image"}: + new_id = f"image_{len(aliases) + 1}" + aliases[item["id"]] = new_id + item = {**item, "id": new_id} + result.append(item) + # A named keyframe marker identifies the legacy namespace. Replace once, + # including its old reference-image IDs, so substitutions cannot cascade. + pattern = r"@(first_frame|middle_frame_[1-9]\d*|last_frame|image_[1-9]\d*)(?![A-Za-z0-9_])" + tokens = re.findall(pattern, prompt or "") + if any(name in aliases and not name.startswith("image_") for name in tokens): + prompt = re.sub(pattern, lambda m: "@" + aliases.get(m[1], m[1]), prompt) + return prompt, result + + def _run(kind, prompt, resolution, duration, aspect_ratio=None, contents=None, **options): payload = build_payload(kind, prompt, resolution, duration, aspect_ratio, contents, **options) with _runtime_client() as client: @@ -90,8 +108,9 @@ class Kling4Image2Video(Kling4Text2Video): def generate(self, prompt, resolution="1080p", duration="10", first_frame=None, middle_frames=None, last_frame=None, watermark=False, callback_url="", external_task_id=""): + prompt, contents = _unify_image_ids(prompt, _frames(first_frame, middle_frames, last_frame)) return _run("image", prompt, resolution, duration, - contents=_frames(first_frame, middle_frames, last_frame), + contents=contents, watermark=watermark, callback_url=callback_url, external_task_id=external_task_id) @@ -102,7 +121,7 @@ class Kling4OmniVideo(Kling4Text2Video): inputs["required"]["aspect_ratio"] = (["adaptive", "21:9", "16:9", "9:16", "1:1"], {"default": "adaptive"}) inputs["optional"].update(_frame_inputs()) inputs["optional"].update({ - "reference_images": ("IMAGE", {"tooltip": "参考图片批次:@image_1、@image_2…;关键帧不占用这些编号。"}), + "reference_images": ("IMAGE", {"tooltip": "旧参考图片批次,接在关键帧之后统一分配 image_N。"}), "base_video": ("VIDEO", {"tooltip": "待编辑视频;有此输入时为 @video_1。与 base_video_url 二选一。"}), "base_video_url": ("STRING", {"default": ""}), "feature_video": ("VIDEO", {"tooltip": "特征参考视频;接在 base_video 后编号,无 base_video 时为 @video_1。"}), @@ -111,7 +130,7 @@ class Kling4OmniVideo(Kling4Text2Video): "custom_voice_list": (CUSTOM_VOICE_LIST_TYPE, {"tooltip": "现有音色列表:@voice_1、@voice_2…"}), }) for index in range(1, 11): - inputs["optional"][f"image_{index}"] = ("IMAGE", {"tooltip": f"单张参考图,引用 @image_{index}"}) + inputs["optional"][f"image_{index}"] = ("IMAGE", {"tooltip": "单张参考图;引用编号在所有关键帧之后,查看接口旁标签。"}) for index in range(1, 6): inputs["optional"][f"feature_video_{index}"] = ("VIDEO", {"tooltip": "参考视频,实际 @video 编号显示在连接口旁。"}) return inputs @@ -165,6 +184,7 @@ class Kling4OmniVideo(Kling4Text2Video): for index, voice in enumerate(_normalize_voice_list(custom_voice_list) or [], 1): contents.append({"type": "voice", "voice_id": voice["voice_id"], "id": f"voice_{index}"}) validate_material_counts(contents) + prompt, contents = _unify_image_ids(prompt, contents) for item in contents: if item["type"] in {"base_video", "feature_video"} and not isinstance(item["url"], str): item["url"] = _upload_video_reference(item["url"]) diff --git a/tests/browser_video_v4_unified.js b/tests/browser_video_v4_unified.js new file mode 100644 index 0000000..89eb019 --- /dev/null +++ b/tests/browser_video_v4_unified.js @@ -0,0 +1,24 @@ +async(page)=>{ + await page.goto('http://127.0.0.1:8199'); + await page.waitForFunction(()=>window.LiteGraph?.registered_node_types['Comfyui-Kling-Wrapper Kling 4.0 OmniVideo']); + await page.waitForLoadState('networkidle'); + await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');app.graph.clear(); + const n=LiteGraph.createNode('Comfyui-Kling-Wrapper Kling 4.0 OmniVideo');app.graph.add(n);window.unifiedNode=n;n.pos=[600,100]; + const images=[];for(let i=0;i<4;i++){const src=LiteGraph.createNode('LoadImage');app.graph.add(src);src.widgets.find(w=>w.name==='image').value='example.jpg';images.push(src);}window.testImages=images; + }); + await page.waitForTimeout(100); + const initial=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const n=window.unifiedNode;const fields=['first_frame','middle_frames','last_frame','image_1'];fields.forEach((f,i)=>window.testImages[i].connect(0,n,n.inputs.findIndex(s=>s.name===f)));return fields;}); + await page.waitForTimeout(150); + await page.evaluate(()=>{const n=window.unifiedNode;['first_frame','middle_frames','last_frame','image_1'].forEach((f,i)=>{if(!n.inputs.find(s=>s.name===f).label.includes('@image_'+(i+1)))throw new Error('Wrong label '+f);});}); + await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const n=window.unifiedNode;const b=LiteGraph.createNode('ImageBatch');app.graph.add(b);window.testImages[1].connect(0,b,0);window.testImages[2].connect(0,b,1);b.connect(0,n,n.inputs.findIndex(s=>s.name==='middle_frames'));window.testBatch=b;}); + await page.waitForTimeout(150); + await page.evaluate(()=>{const n=window.unifiedNode;if(!n.inputs.find(s=>s.name==='middle_frames').label.includes('@image_2、@image_3'))throw new Error('Batch numbering');if(!n.inputs.find(s=>s.name==='last_frame').label.includes('@image_4'))throw new Error('Tail after batch');if(!n.inputs.find(s=>s.name==='image_1').label.includes('@image_5'))throw new Error('Reference after batch');}); + await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const inner=LiteGraph.createNode('ImageBatch');app.graph.add(inner);window.testImages[0].connect(0,inner,0);window.testImages[3].connect(0,inner,1);inner.connect(0,window.testBatch,0);app.canvas.draw(true,true);}); + await page.waitForTimeout(150); + const result=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const n=window.unifiedNode;if(!n.inputs.find(s=>s.name==='image_1').label.includes('@image_6'))throw new Error('Upstream batch change left stale labels');const saved=app.graph.serialize();await app.loadGraphData(saved);return {passed:true,knownBatch:true,upstreamBatchChange:true};}); + await page.waitForTimeout(150); + await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const n=app.graph._nodes.find(n=>n.type.includes('Kling 4.0'));const unknown=LiteGraph.createNode('EmptyImage');app.graph.add(unknown);unknown.connect(0,n,n.inputs.findIndex(s=>s.name==='middle_frames'));window.unifiedNode=n;}); + await page.waitForTimeout(150); + await page.evaluate(()=>{const n=window.unifiedNode;const label=n.inputs.find(s=>s.name==='image_1').label;if(!label.includes('待执行确定')||label.includes('@image_'))throw new Error('Unknown batch must not guess '+label);}); + return {...result,unknownBatch:true}; +} diff --git a/tests/test_video_v4.py b/tests/test_video_v4.py index 9bdc6ec..d242992 100644 --- a/tests/test_video_v4.py +++ b/tests/test_video_v4.py @@ -157,7 +157,25 @@ class VideoV4Tests(unittest.TestCase): def test_dynamic_ids_preserve_gaps(self): with patch.object(nodes, '_image_batch_to_base64_list', return_value=['img']), patch.object(nodes, '_upload_video_reference', return_value='url'), patch.object(nodes, '_run') as run: nodes.Kling4OmniVideo().generate('ref', image_2=object(), feature_video_3=object(), base_video_url='https://example.com/v.mp4') - self.assertEqual([c['id'] for c in run.call_args.args[5]], ['image_2', 'video_1', 'video_4']) + self.assertEqual([c['id'] for c in run.call_args.args[5]], ['image_1', 'video_1', 'video_4']) + + def test_unified_images_include_all_keyframes_and_references(self): + with patch.object(nodes, '_image_batch_to_base64_list', side_effect=[['first'], ['mid1', 'mid2'], ['last'], ['ref']]), patch.object(nodes, '_run') as run: + nodes.Kling4OmniVideo().generate('@image_1 到 @image_5', first_frame=object(), middle_frames=object(), last_frame=object(), image_3=object()) + contents = run.call_args.args[5] + self.assertEqual([c['id'] for c in contents], ['image_1', 'image_2', 'image_3', 'image_4', 'image_5']) + self.assertEqual([c['url'] for c in contents], ['first', 'mid1', 'mid2', 'last', 'ref']) + + def test_legacy_frame_aliases_and_reference_ids_convert_simultaneously(self): + with patch.object(nodes, '_image_batch_to_base64_list', side_effect=[['first'], ['ref']]), patch.object(nodes, '_run') as run: + nodes.Kling4OmniVideo().generate('@first_frame的人穿@image_1的衣服', first_frame=object(), image_1=object()) + self.assertEqual(run.call_args.args[1], '@image_1的人穿@image_2的衣服') + + def test_image_node_uses_unified_frame_ids(self): + with patch.object(nodes, '_image_batch_to_base64_list', side_effect=[['first'], ['mid'], ['last']]), patch.object(nodes, '_run') as run: + nodes.Kling4Image2Video().generate('@first_frame 到 @middle_frame_1 到 @last_frame', first_frame=object(), middle_frames=object(), last_frame=object()) + self.assertEqual(run.call_args.kwargs['contents'][2]['id'], 'image_3') + self.assertEqual(run.call_args.args[1], '@image_1 到 @image_2 到 @image_3') def test_total_limit_before_upload(self): with patch.object(nodes, '_image_batch_to_base64_list', return_value=['img'] * 10), patch.object(nodes, '_upload_video_reference') as upload, patch.object(nodes, '_run'): diff --git a/web/js/kling4DynamicInputs.js b/web/js/kling4DynamicInputs.js index 65a1406..a9006d7 100644 --- a/web/js/kling4DynamicInputs.js +++ b/web/js/kling4DynamicInputs.js @@ -1,4 +1,5 @@ import { app } from "../../../scripts/app.js"; +import { updateImageReferences, IMAGE_ORDER_HELP, legacyImageWarning } from "./kling4ImageReferences.js"; const NODE = "Comfyui-Kling-Wrapper Kling 4.0 OmniVideo"; @@ -39,11 +40,13 @@ export function stabilizeInputs(node) { if (baseInput) baseInput.label = "base_video → @video_1(接入时占用)"; const legacy = node.inputs.find(i => i.name === "feature_video"); if (legacy) legacy.label = `feature_video → @video_${base ? 2 : 1}(旧输入)`; - const markers = node.inputs.filter(i => i.kling4Reference).map(i => i.kling4Reference); + const images = updateImageReferences(node); + const markers = [...images.markers, ...node.inputs.filter(i => i.name.startsWith("feature_video_") && i.kling4Reference).map(i => i.kling4Reference)]; + if (!images.markers.length && !images.uncertain) markers.unshift("image_1"); if (base || legacyVideo) markers.push("video_1"); if (base && legacyVideo) markers.push("video_2"); - node.kling4ReferencePreview?.setReferences([...new Set([...markers, "element_1", "voice_1", "first_frame", "middle_frame_1", "last_frame"])], - `${base ? "base_video = @video_1;特征视频引用号已后移。" : "未提供 base_video;特征视频从 @video_1 开始。"} 增删 base_video 会改变视频引用号,请检查提示词。总素材≤15(含关键帧、主体和音色),执行前按实际数量校验。`); + node.kling4ReferencePreview?.setReferences([...new Set([...markers, "element_1", "voice_1"])], + `${IMAGE_ORDER_HELP} ${images.uncertain ? "上游批次张数未知,后续图片编号将在执行时确定。" : ""} ${base ? "base_video = @video_1;特征视频引用号已后移。" : "未提供 base_video;特征视频从 @video_1 开始。"} 总素材≤15,执行前按实际数量校验。${legacyImageWarning(node)}`); } finally { node.kling4Stabilizing = false; } @@ -68,6 +71,7 @@ app.registerExtension({ const result = original?.apply(this, arguments); schedule(this); if (event === "onNodeCreated") { + this.kling4RefreshReferences = () => schedule(this); const widget = this.widgets?.find(w => w.name === "base_video_url"); if (widget) { const callback = widget.callback; diff --git a/web/js/kling4ImageReferences.js b/web/js/kling4ImageReferences.js new file mode 100644 index 0000000..778e70b --- /dev/null +++ b/web/js/kling4ImageReferences.js @@ -0,0 +1,86 @@ +function loadedImageCount(node) { + const value = String(node.widgets?.find(w => w.name === "image")?.value || ""); + if (/\.(jpe?g|bmp)(?: \[(?:input|output|temp)\])?$/i.test(value)) return 1; + if (node.kling4ImageCount?.value === value) return node.kling4ImageCount.count; + const state = { value, count: null }; + node.kling4ImageCount = state; + if (!value || typeof ImageDecoder === "undefined") return null; + const annotated = value.match(/ \[(input|output|temp)\]$/); + const filename = value.replace(/ \[(input|output|temp)\]$/, "").replaceAll("\\", "/"); + const slash = filename.lastIndexOf("/"); + const params = new URLSearchParams({ filename: filename.slice(slash + 1), subfolder: filename.slice(0, Math.max(slash, 0)), type: annotated?.[1] || "input" }); + const extension = filename.split(".").pop().toLowerCase(); + const type = { png: "image/png", apng: "image/png", webp: "image/webp", gif: "image/gif" }[extension]; + if (!type) return null; + (async () => { + let decoder; + try { + if (!await ImageDecoder.isTypeSupported(type)) return; + const response = await fetch(`/view?${params}`); + if (!response.ok) return; + decoder = new ImageDecoder({ data: await response.arrayBuffer(), type }); + await decoder.tracks.ready; + await decoder.completed; + const count = decoder.tracks.selectedTrack?.frameCount; + if (Number.isInteger(count) && count > 0) state.count = count; + } catch { + // Unknown or unreadable batches stay explicitly unnumbered. + } finally { + decoder?.close(); + node.graph?.setDirtyCanvas(true, true); + } + })(); + return null; +} + +function batchSize(node, slot, seen = new Set()) { + const upstream = node.getInputNode(slot); + if (!upstream || seen.has(upstream.id)) return null; + const next = new Set(seen).add(upstream.id); + if (upstream.type === "LoadImage") return loadedImageCount(upstream); + if (upstream.type === "ImageBatch") { + const sizes = upstream.inputs.map((input, i) => batchSize(upstream, i, next)); + return sizes.every(n => n != null) ? sizes.reduce((a, b) => a + b, 0) : null; + } + if (upstream.type === "Reroute") return batchSize(upstream, 0, next); + return null; +} + +export function updateImageReferences(node) { + const names = ["first_frame", "middle_frames", "last_frame"]; + names.push(...node.inputs.filter(i => /^image_\d+$/.test(i.name)) + .sort((a, b) => Number(a.name.split("_")[1]) - Number(b.name.split("_")[1])).map(i => i.name)); + names.push("reference_images"); + let next = 1; + const markers = []; + let uncertain = false; + for (const name of names) { + const slot = node.inputs.findIndex(i => i.name === name); + if (slot < 0) continue; + const input = node.inputs[slot]; + delete input.kling4Reference; + if (input.link == null) { + input.label = `${name}(未连接)`; + continue; + } + const count = ["middle_frames", "reference_images"].includes(name) ? batchSize(node, slot) : 1; + if (count == null) uncertain = true; + if (uncertain) { + input.label = `${name} → image_N(批次数量待执行确定)`; + continue; + } + const refs = Array.from({ length: count }, (_, index) => `image_${next + index}`); + input.label = `${name} → ${refs.map(id => `@${id}`).join("、")}`; + input.kling4Reference = refs[0]; + markers.push(...refs); + next += count; + } + return { markers, uncertain }; +} + +export const IMAGE_ORDER_HELP = "图片统一按首帧→中间帧→尾帧→参考图编号,增删前面的图片会改变后续 image_N,请检查提示词。"; + +export function legacyImageWarning(node) { + return node.properties?.kling4UnifiedImageIds === 1 + ? " 旧 workflow 提示:图片已改为统一编号,请核对原 @image_N 的指向;旧关键帧别名仍会兼容转换。" : ""; +} diff --git a/web/js/kling4References.js b/web/js/kling4References.js index efe3fb5..844ac54 100644 --- a/web/js/kling4References.js +++ b/web/js/kling4References.js @@ -1,4 +1,5 @@ import { app } from "../../../scripts/app.js"; +import { updateImageReferences, IMAGE_ORDER_HELP, legacyImageWarning } from "./kling4ImageReferences.js"; const PREFIX = "Comfyui-Kling-Wrapper Kling 4.0 "; const COLORS = { @@ -46,7 +47,7 @@ export function attachReferencePreview(node, kind) { width: "100%", height: "100%", overflow: "auto", }); const title = document.createElement("div"); - title.textContent = "引用预览 · 彩色标记仅辅助阅读,实际提交原始提示词"; + title.textContent = "引用预览 · 旧关键帧别名在提交时兼容转换为 image_N"; title.style.color = "#aab8cb"; const buttons = document.createElement("div"); Object.assign(buttons.style, { display: "flex", flexWrap: "wrap", gap: "5px", margin: "6px 0" }); @@ -55,9 +56,7 @@ export function attachReferencePreview(node, kind) { const help = document.createElement("div"); help.style.color = "#aab8cb"; help.style.fontSize = "11px"; - help.textContent = kind === "OmniVideo" - ? "图片按参考图批次编号;视频按待编辑→特征 VIDEO→URL 列表编号;主体/音色按列表编号。点击标签插入,须连接对应素材。" - : "首尾帧各一张,中间帧按批次编号。点击标签插入,须连接对应素材。"; + help.textContent = IMAGE_ORDER_HELP; root.append(title, buttons, preview, help); const refresh = () => renderReferences(preview, String(prompt.value ?? "")); @@ -77,9 +76,12 @@ export function attachReferencePreview(node, kind) { prompt.callback = callback; const markers = kind === "OmniVideo" - ? ["image_1", "video_1", "element_1", "voice_1", "first_frame", "middle_frame_1", "last_frame"] - : ["first_frame", "middle_frame_1", "last_frame"]; + ? ["image_1", "video_1", "element_1", "voice_1"] + : ["image_1"]; function setReferences(markers, description) { + const signature = JSON.stringify([markers, description]); + if (root.dataset.referenceSignature === signature) return; + root.dataset.referenceSignature = signature; buttons.replaceChildren(); if (description) help.textContent = description; for (const marker of markers) { @@ -140,15 +142,40 @@ app.registerExtension({ const created = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = created?.apply(this, arguments); + this.properties.kling4UnifiedImageIds = 2; attachReferencePreview(this, kind); + if (kind === "Image2Video") refreshImageLabels(this); + return result; + }; + const draw = nodeType.prototype.onDrawBackground; + nodeType.prototype.onDrawBackground = function () { + const result = draw?.apply(this, arguments); + // Upstream ImageBatch changes do not change this node's own links. + const images = updateImageReferences(this); + const signature = JSON.stringify(images); + if (signature !== this.kling4ImageSignature) { + this.kling4ImageSignature = signature; + if (kind === "Image2Video") refreshImageLabels(this); + else this.kling4RefreshReferences?.(); + } return result; }; const configured = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = configured?.apply(this, arguments); + this.properties.kling4UnifiedImageIds = arguments[0]?.properties?.kling4UnifiedImageIds || 1; this.kling4ReferencePreview?.refresh(); + if (kind === "Image2Video") refreshImageLabels(this); return result; }; + if (kind === "Image2Video") { + const connections = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function () { + const result = connections?.apply(this, arguments); + refreshImageLabels(this); + return result; + }; + } const removed = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { this.kling4ReferencePreview?.dispose(); @@ -156,3 +183,10 @@ app.registerExtension({ }; }, }); + +function refreshImageLabels(node) { + const images = updateImageReferences(node); + node.kling4ReferencePreview?.setReferences(images.markers.length ? images.markers : images.uncertain ? [] : ["image_1"], + IMAGE_ORDER_HELP + (images.uncertain ? " 上游批次张数未知,后续编号执行时确定。" : "") + legacyImageWarning(node)); + node.graph?.setDirtyCanvas(true, true); +}