From dd7630614885cef45884fcd1dfb345c64b89c216 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 6 Feb 2025 23:09:46 +0800 Subject: [PATCH] Supports direct processing of video output and can simultaneously handle workflows with both images and videos. --- RH_ExecuteNode.py | 237 ++++++------ examples/hunyuanTextToVideo.json | 400 +++++++++++++------- examples/rh_video_save_to_local_direct.json | 220 +++++++++++ 3 files changed, 584 insertions(+), 273 deletions(-) create mode 100644 examples/rh_video_save_to_local_direct.json diff --git a/RH_ExecuteNode.py b/RH_ExecuteNode.py index 83cd2cc..23aeb1c 100644 --- a/RH_ExecuteNode.py +++ b/RH_ExecuteNode.py @@ -1,11 +1,11 @@ import requests import time import json -from PIL import Image, ImageOps +from PIL import Image from io import BytesIO import numpy as np import torch -import os # 确保导入 os 库 +import os class ExecuteNode: def __init__(self): @@ -16,30 +16,26 @@ class ExecuteNode: return { "required": { "apiConfig": ("STRUCT",), # 设置节点的输入 - "nodeInfoList": ("ARRAY", {"default": []}), # NodeInfoList节点的输出 }, "optional": { + "nodeInfoList": ("ARRAY", {"default": []}), # NodeInfoList节点的输出, 设置为可选 "run_timeout": ("INT", {"default": 600}), # 最大运行超时时间(秒) "query_interval": ("INT", {"default": 10}), # 查询间隔时间(秒) }, } - RETURN_TYPES = ("IMAGE",) # 支持单张和多张图片输出 - RETURN_NAMES = ("images",) # 定义返回名称,与 ComfyUI 的预期匹配 + RETURN_TYPES = ("IMAGE", "VIDEO") # 支持单张和多张图片输出,也支持视频 + RETURN_NAMES = ("images", "videos") # 定义返回名称,与 ComfyUI 的预期匹配 CATEGORY = "RunningHub" FUNCTION = "process" # 指向 process 方法 - def process(self, apiConfig, nodeInfoList, run_timeout=600, query_interval=2): + def process(self, apiConfig, nodeInfoList=None, run_timeout=600, query_interval=2): # Ensure query_interval is not less than 2 if query_interval < 2: print("Query interval is too low, setting to minimum value of 2 seconds.") query_interval = 2 - """ - 该节点通过调用 RunningHub API 创建任务并返回生成的图片链接。 - """ - # 打印请求数据,方便调试 print(f"API request data: {apiConfig}") print(f"Node info list: {nodeInfoList}") print(f"Run timeout: {run_timeout} seconds") @@ -49,19 +45,17 @@ class ExecuteNode: account_status = self.check_account_status(apiConfig["apiKey"], apiConfig["base_url"]) if int(account_status["currentTaskCounts"]) > 0: print("There are tasks running, waiting for them to finish.") - # 等待最多 run_timeout 秒,如果任务未完成,则超时 start_time = time.time() while account_status["currentTaskCounts"] > 0 and time.time() - start_time < run_timeout: - time.sleep(query_interval) # 每 query_interval 秒查询一次 + time.sleep(query_interval) account_status = self.check_account_status(apiConfig["apiKey"], apiConfig["base_url"]) if int(account_status["currentTaskCounts"]) > 0: raise Exception(f"Timeout: There are still running tasks after {run_timeout} seconds.") - # Print nodeInfoList for debugging print(f"ExecuteNode NodeInfoList: {nodeInfoList}") - # 2. 创建任务 - task_creation_result = self.create_task(apiConfig, nodeInfoList, apiConfig["base_url"]) + # 2. 创建任务,如果 nodeInfoList 为空则不传递 + task_creation_result = self.create_task(apiConfig, nodeInfoList or [], apiConfig["base_url"]) if task_creation_result["code"] != 0: raise Exception(f"Task creation failed: {task_creation_result['msg']}") @@ -73,14 +67,13 @@ class ExecuteNode: task_start_time = time.time() while task_status != "success": print(f"Task still running, checking again in {query_interval} seconds...") - time.sleep(query_interval) # 每 query_interval 秒检查一次任务状态 + time.sleep(query_interval) task_status_result = self.check_task_status(task_id, apiConfig["apiKey"], apiConfig["base_url"]) print(f"Task info, taskId: {task_id}, status: {task_status_result}") if isinstance(task_status_result, dict): - task_status = task_status_result.get("taskStatus", "unknown") # 从结果中获取任务状态 + task_status = task_status_result.get("taskStatus", "unknown") elif isinstance(task_status_result, list): - # 假设任务完成后返回的数据列表表示任务成功 task_status = "success" else: task_status = "unknown" @@ -89,13 +82,105 @@ class ExecuteNode: print(f"Task failed or completed with status: {task_status}") break - # 检查是否超过 run_timeout if time.time() - task_start_time > run_timeout: raise Exception(f"Timeout: Task {task_id} did not complete within {run_timeout} seconds.") # 4. 任务完成,处理输出 return self.process_task_output(task_id, apiConfig["apiKey"], apiConfig["base_url"]) + def process_task_output(self, task_id, api_key, base_url): + """ + 处理任务输出,返回文件链接。 + 返回图像和视频,若无图像则返回空图像,若无视频则返回空视频。 + """ + task_status_result = self.check_task_status(task_id, api_key, base_url) + + print("Task Status Result:", json.dumps(task_status_result, indent=4, ensure_ascii=False)) + + image_urls = [] + video_urls = [] + + # 检查任务是否返回了图像 + if isinstance(task_status_result, dict): + file_url = task_status_result.get("fileUrl") + file_type = task_status_result.get("fileType") + if file_url and file_type.lower() in ["png", "jpg", "jpeg"]: + image_urls.append(file_url) + elif file_url and file_type.lower() in ["mp4", "avi", "mov"]: + video_urls.append(file_url) + elif isinstance(task_status_result, list): + for output in task_status_result: + if isinstance(output, dict): + file_url = output.get("fileUrl") + file_type = output.get("fileType") + if file_url and file_type.lower() in ["png", "jpg", "jpeg"]: + image_urls.append(file_url) + elif file_url and file_type.lower() in ["mp4", "avi", "mov"]: + video_urls.append(file_url) + + if not image_urls: + image_urls = None # No images found, set to None + if not video_urls: + video_urls = None # No videos found, set to None + + # Download and process images if available + image_data_list = [] + if image_urls: + for url in image_urls: + print("Downloading image from URL:", url) + image_tensor = self.download_image(url) + image_data_list.append(image_tensor) + + # 下载视频并保存在本地 + video_data_list = [] + if video_urls: + for url in video_urls: + print("Downloading video from URL:", url) + video_path = self.download_video(url) + video_data_list.append(video_path) + + print(f"Returning {len(image_data_list)} images and {len(video_data_list)} videos.") + return (image_data_list, video_data_list) + + def download_image(self, image_url): + """ + 从 URL 下载图像并转换为适合预览或保存的 torch.Tensor 格式。 + """ + response = requests.get(image_url) + + if response.status_code == 200: + img = Image.open(BytesIO(response.content)).convert("RGB") + img_array = np.array(img).astype(np.float32) / 255.0 + img_tensor = torch.from_numpy(img_array) + return img_tensor + else: + raise Exception(f"Failed to download image: {image_url}") + + def download_video(self, video_url): + """ + 从 URL 下载视频并保存到本地。 + """ + response = requests.get(video_url, stream=True) + if response.status_code == 200: + # 确保保存视频的目录存在 + output_dir = "output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # 获取视频文件名并保存 + video_filename = f"RH_output_video_{str(int(time.time()))}.mp4" + video_path = os.path.join(output_dir, video_filename) + + with open(video_path, "wb") as f: + for chunk in response.iter_content(chunk_size=1024): + if chunk: + f.write(chunk) + + print(f"Video saved to {video_path}") + return video_path + else: + raise Exception(f"Failed to download video: {video_url}") + def check_account_status(self, api_key, base_url): """ 查询账户状态,检查是否可以提交新任务 @@ -113,7 +198,6 @@ class ExecuteNode: result = response.json() if result["code"] != 0: raise Exception(f"Failed to get account status: {result['msg']}") - # 检查并确保 currentTaskCounts 是整数 try: current_task_counts = int(result["data"]["currentTaskCounts"]) except ValueError: @@ -134,14 +218,7 @@ class ExecuteNode: data = { "workflowId": apiConfig["workflowId"], "apiKey": apiConfig["apiKey"], - "nodeInfoList": [ - { - "nodeId": int(nodeInfo["nodeId"]), # 确保 nodeId 为整数类型 - "fieldName": nodeInfo["fieldName"], - "fieldValue": nodeInfo["fieldValue"], - } - for nodeInfo in nodeInfoList - ], + "nodeInfoList": nodeInfoList, # 如果nodeInfoList为空,传递空列表 } response = requests.post(url, json=data, headers=headers) @@ -163,7 +240,6 @@ class ExecuteNode: response = requests.post(url, json=data, headers=headers) - # 打印响应以便调试 print("Response Status Code:", response.status_code) try: response_json = response.json() @@ -176,114 +252,23 @@ class ExecuteNode: result = response.json() - # 检查是否有数据结果 if result.get("data") and isinstance(result["data"], list): if len(result["data"]) > 0: - return result["data"] # 返回整个列表,表示任务成功并且有结果 + return result["data"] else: - # data 是空列表,可能是任务还在运行,或出错 if result.get("code") != 0: msg = result.get("msg") if msg != "APIKEY_TASK_IS_RUNNING": - return {"error": msg} # 出现了错误,返回错误信息 + return {"error": msg} else: - return {"taskStatus": "RUNNING"} # 任务仍在运行 - return {"taskStatus": "RUNNING"} # 如果没有其他信息,认为任务在运行中 + return {"taskStatus": "RUNNING"} + return {"taskStatus": "RUNNING"} - # 如果没有 data 字段或 data 不是列表,检查任务状态 if result.get("code") != 0: msg = result.get("msg") if msg != "APIKEY_TASK_IS_RUNNING": - return {"error": msg} # 返回错误信息 + return {"error": msg} else: - return {"taskStatus": "RUNNING"} # 任务仍在运行 + return {"taskStatus": "RUNNING"} return {"taskStatus": "UNKNOWN_ERROR"} - - def process_task_output(self, task_id, api_key, base_url): - """ - 处理任务输出,返回文件链接。 - """ - task_status_result = self.check_task_status(task_id, api_key, base_url) - - # 记录任务状态结果以了解其结构 - print("Task Status Result:", json.dumps(task_status_result, indent=4, ensure_ascii=False)) - - image_urls = [] - - # 确保 task_status_result 是字典或列表 - if isinstance(task_status_result, dict): - # 检查 fileUrl 和 fileType - file_url = task_status_result.get("fileUrl") - file_type = task_status_result.get("fileType") - if file_url and file_type.lower() in ["png", "jpg", "jpeg"]: - image_urls.append(file_url) - elif isinstance(task_status_result, list): - for output in task_status_result: - if isinstance(output, dict): - file_url = output.get("fileUrl") - file_type = output.get("fileType") - if file_url and file_type.lower() in ["png", "jpg", "jpeg"]: - image_urls.append(file_url) - - if not image_urls: - raise Exception("No valid image output found.") - - # 下载并处理所有图像 - image_data_list = [] - for url in image_urls: - print("Downloading image from URL:", url) # 记录图像 URL - image_tensor = self.download_image(url) # 下载并处理图像 - print("Image downloaded and processed successfully.") - - # 打印张量信息,避免在 uint8 上调用 mean() - print(f"Image tensor shape: {image_tensor.shape}") - mean_val = image_tensor.mean() - print(f"Image tensor min: {image_tensor.min()}, max: {image_tensor.max()}, mean: {mean_val}") - - # 确保返回的是 torch.Tensor - if not isinstance(image_tensor, torch.Tensor): - raise TypeError(f"Expected torch.Tensor, got {type(image_tensor)}") - - image_data_list.append(image_tensor) - - print(f"Returning {len(image_data_list)} images.") - return (image_data_list,) # 返回一个包含图像列表的元组,与 RETURN_TYPES 和 RETURN_NAMES 匹配 - - def download_image(self, image_url): - """ - 从 URL 下载图像并转换为适合预览或保存的 torch.Tensor 格式。 - """ - response = requests.get(image_url) - - if response.status_code == 200: - img = Image.open(BytesIO(response.content)).convert("RGB") - - # 转换为 numpy 数组并调整数据类型和范围 - img_array = np.array(img).astype(np.float32) / 255.0 # 归一化到 [0, 1] - - # **保持形状为 [H, W, C],不进行 permute 操作** - img_tensor = torch.from_numpy(img_array) # 形状 [H, W, C] - - # 打印图像尺寸 - print(f"Downloaded image dimensions: {img_tensor.shape}") # 例如 (高度, 宽度, 3) - - return img_tensor - - else: - raise Exception(f"Failed to download image: {image_url}") - - def download_video(self, video_url): - """ - 从 URL 下载视频。 - 根据 ComfyUI 的要求实现此方法。 - """ - response = requests.get(video_url, stream=True) - if response.status_code == 200: - # 示例:将视频保存到临时位置并返回路径或数据 - video_content = response.content - # 您可能需要根据 ComfyUI 的要求处理视频数据 - # 目前,返回原始字节 - return video_content - else: - raise Exception(f"Failed to download video: {video_url}") diff --git a/examples/hunyuanTextToVideo.json b/examples/hunyuanTextToVideo.json index 006bd44..e92fbe5 100644 --- a/examples/hunyuanTextToVideo.json +++ b/examples/hunyuanTextToVideo.json @@ -1,61 +1,7 @@ { - "last_node_id": 42, - "last_link_id": 32, + "last_node_id": 44, + "last_link_id": 34, "nodes": [ - { - "id": 17, - "type": "RH_NodeInfoListNode", - "pos": [ - 68, - 1440 - ], - "size": { - "0": 330, - "1": 106 - }, - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [ - { - "name": "previousNodeInfoList", - "type": "ARRAY", - "link": null, - "label": "previousNodeInfoList", - "shape": 7 - }, - { - "name": "fieldValue", - "type": "STRING", - "link": 32, - "widget": { - "name": "fieldValue" - }, - "label": "fieldValue", - "slot_index": 1 - } - ], - "outputs": [ - { - "name": "ARRAY", - "type": "ARRAY", - "links": [ - 23 - ], - "slot_index": 0, - "shape": 3, - "label": "ARRAY" - } - ], - "properties": { - "Node name for S&R": "RH_NodeInfoListNode" - }, - "widgets_values": [ - 90, - "text", - "土星环" - ] - }, { "id": 42, "type": "String", @@ -88,96 +34,12 @@ "wh32,一个采光很好的房间,中午,女子穿着蕾丝睡衣在床上躺着,正面对着镜头漏出了微笑" ] }, - { - "id": 30, - "type": "RH_ExecuteNode", - "pos": [ - 526, - 1477 - ], - "size": { - "0": 315, - "1": 102 - }, - "flags": {}, - "order": 3, - "mode": 0, - "inputs": [ - { - "name": "apiConfig", - "type": "STRUCT", - "link": 20, - "label": "apiConfig" - }, - { - "name": "nodeInfoList", - "type": "ARRAY", - "link": 23, - "label": "nodeInfoList" - } - ], - "outputs": [ - { - "name": "images", - "type": "IMAGE", - "links": [ - 30 - ], - "slot_index": 0, - "shape": 3, - "label": "images" - } - ], - "properties": { - "Node name for S&R": "RH_ExecuteNode" - }, - "widgets_values": [ - 600, - 2 - ] - }, - { - "id": 9, - "type": "RH_SettingsNode", - "pos": [ - 53, - 1227 - ], - "size": [ - 502.2504859687508, - 137.38932101438627 - ], - "flags": {}, - "order": 1, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "STRUCT", - "type": "STRUCT", - "links": [ - 20 - ], - "slot_index": 0, - "shape": 3, - "label": "STRUCT" - } - ], - "properties": { - "Node name for S&R": "RH_SettingsNode" - }, - "widgets_values": [ - "https://www.runninghub.cn", - "ed37fbdd79c34a7ca612aedbe5cea13e", - "1871595400514633730" - ] - }, { "id": 39, "type": "VHS_VideoCombine", "pos": [ - 887, - 1224 + 1395, + 1231 ], "size": [ 214.7587890625, @@ -239,16 +101,244 @@ "hidden": false, "paused": false, "params": { - "filename": "AnimateDiff_00002.mp4", + "filename": "AnimateDiff_00005.mp4", "subfolder": "", "type": "output", "format": "video/h264-mp4", "frame_rate": 24, - "workflow": "AnimateDiff_00002.png" + "workflow": "AnimateDiff_00005.png" }, "muted": false } } + }, + { + "id": 9, + "type": "RH_SettingsNode", + "pos": [ + 53, + 1227 + ], + "size": { + "0": 502.25048828125, + "1": 137.3893280029297 + }, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRUCT", + "type": "STRUCT", + "links": [ + 20 + ], + "slot_index": 0, + "shape": 3, + "label": "STRUCT" + } + ], + "properties": { + "Node name for S&R": "RH_SettingsNode" + }, + "widgets_values": [ + "https://www.runninghub.cn", + "ed37fbdd79c34a7ca612aedbe5cea13e", + "1871595400514633730" + ] + }, + { + "id": 30, + "type": "RH_ExecuteNode", + "pos": [ + 550, + 1468 + ], + "size": { + "0": 315, + "1": 102 + }, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "apiConfig", + "type": "STRUCT", + "link": 20, + "label": "apiConfig" + }, + { + "name": "nodeInfoList", + "type": "ARRAY", + "link": 23, + "label": "nodeInfoList" + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [ + 30 + ], + "slot_index": 0, + "shape": 3, + "label": "images" + }, + { + "name": "videos", + "type": "VIDEO", + "links": [ + 33 + ], + "shape": 3, + "label": "videos", + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "RH_ExecuteNode" + }, + "widgets_values": [ + 600, + 5 + ] + }, + { + "id": 17, + "type": "RH_NodeInfoListNode", + "pos": [ + 82, + 1435 + ], + "size": { + "0": 330, + "1": 106 + }, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "previousNodeInfoList", + "type": "ARRAY", + "link": null, + "label": "previousNodeInfoList", + "shape": 7 + }, + { + "name": "fieldValue", + "type": "STRING", + "link": 32, + "widget": { + "name": "fieldValue" + }, + "label": "fieldValue", + "slot_index": 1 + } + ], + "outputs": [ + { + "name": "ARRAY", + "type": "ARRAY", + "links": [ + 23 + ], + "slot_index": 0, + "shape": 3, + "label": "ARRAY" + } + ], + "properties": { + "Node name for S&R": "RH_NodeInfoListNode" + }, + "widgets_values": [ + 90, + "text", + "土星环" + ] + }, + { + "id": 43, + "type": "easy showAnythingLazy", + "pos": [ + 943, + 1509 + ], + "size": { + "0": 210, + "1": 26 + }, + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "anything", + "type": "*", + "link": 33, + "label": "anything" + } + ], + "outputs": [ + { + "name": "output", + "type": "*", + "links": [ + 34 + ], + "shape": 3, + "label": "output", + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "easy showAnythingLazy" + } + }, + { + "id": 44, + "type": "ShowText|pysssss", + "pos": [ + 928, + 1639 + ], + "size": [ + 315, + 76 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 34, + "widget": { + "name": "text" + }, + "label": "文本" + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null, + "shape": 6, + "label": "字符串" + } + ], + "properties": { + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "", + "output\\RH_output_video_1738854232.mp4" + ] } ], "links": [ @@ -283,16 +373,32 @@ 17, 1, "STRING" + ], + [ + 33, + 30, + 1, + 43, + 0, + "*" + ], + [ + 34, + 43, + 0, + 44, + 0, + "STRING" ] ], "groups": [], "config": {}, "extra": { "ds": { - "scale": 0.8769226950000008, + "scale": 0.9646149645000006, "offset": [ - 589.8700344728978, - -719.2667377838786 + 197.9158053009368, + -912.3150194555913 ] }, "ue_links": [], diff --git a/examples/rh_video_save_to_local_direct.json b/examples/rh_video_save_to_local_direct.json new file mode 100644 index 0000000..b8b96e9 --- /dev/null +++ b/examples/rh_video_save_to_local_direct.json @@ -0,0 +1,220 @@ +{ + "last_node_id": 53, + "last_link_id": 42, + "nodes": [ + { + "id": 9, + "type": "RH_SettingsNode", + "pos": [ + 66, + 1357 + ], + "size": { + "0": 502.25048828125, + "1": 137.3893280029297 + }, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRUCT", + "type": "STRUCT", + "links": [ + 33 + ], + "slot_index": 0, + "shape": 3, + "label": "STRUCT" + } + ], + "properties": { + "Node name for S&R": "RH_SettingsNode" + }, + "widgets_values": [ + "https://www.runninghub.cn", + "ed37fbdd79c34a7ca612aedbe5cea13e", + "1887510793653780481" + ] + }, + { + "id": 43, + "type": "RH_ExecuteNode", + "pos": [ + 635, + 1367 + ], + "size": { + "0": 315, + "1": 102 + }, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "apiConfig", + "type": "STRUCT", + "link": 33, + "label": "apiConfig" + }, + { + "name": "nodeInfoList", + "type": "ARRAY", + "link": null, + "label": "nodeInfoList", + "slot_index": 1 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [], + "shape": 3, + "label": "images", + "slot_index": 0 + }, + { + "name": "videos", + "type": "VIDEO", + "links": [ + 36 + ], + "shape": 3, + "label": "videos", + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "RH_ExecuteNode" + }, + "widgets_values": [ + 600, + 10 + ] + }, + { + "id": 44, + "type": "easy showAnythingLazy", + "pos": [ + 992, + 1373 + ], + "size": { + "0": 210, + "1": 26 + }, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "anything", + "type": "*", + "link": 36, + "label": "anything" + } + ], + "outputs": [ + { + "name": "output", + "type": "*", + "links": [ + 39 + ], + "shape": 3, + "label": "output", + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "easy showAnythingLazy" + } + }, + { + "id": 47, + "type": "ShowText|pysssss", + "pos": [ + 584, + 1572 + ], + "size": { + "0": 347.5731201171875, + "1": 242.42494201660156 + }, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 39, + "widget": { + "name": "text" + }, + "label": "文本" + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null, + "shape": 6, + "label": "字符串" + } + ], + "properties": { + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "", + "output\\RH_output_video_1738853689.mp4" + ] + } + ], + "links": [ + [ + 33, + 9, + 0, + 43, + 0, + "STRUCT" + ], + [ + 36, + 43, + 1, + 44, + 0, + "*" + ], + [ + 39, + 44, + 0, + 47, + 0, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1.0610764609500007, + "offset": [ + 143.41590852037982, + -895.2557214802658 + ] + }, + "ue_links": [], + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0 + }, + "version": 0.4 +} \ No newline at end of file