diff --git a/README.md b/README.md index 895f166..be8e3d8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ For business cooperation, please contact email 389570357@qq.com ##### `最新`: +- 新增[fal.ai](https://fal.ai/dashboard)的视频生成:Kling、RunwayGen3、LumaDreamMachine,[工作流下载](./workflow/video-all-in-one-test-workflow.json) + - 新增 SimulateDevDesignDiscussions,需要安装[swarm](https://github.com/openai/swarm)和[Comfyui-ChatTTS](https://github.com/shadowcz007/Comfyui-ChatTTS),[工作流下载](./workflow/swarm制作的播客节点workflow.json) - 新增 SenseVoice diff --git a/__init__.py b/__init__.py index bd6a263..f504748 100644 --- a/__init__.py +++ b/__init__.py @@ -1455,4 +1455,23 @@ try: except Exception as e: logging.info('Whisper.available False' ) + +try: + from .nodes.FalVideo import VideoGenKlingNode,VideoGenLumaDreamMachineNode,VideoGenRunwayGen3Node,LoadVideoFromURL + logging.info('FalVideo.available') + # Update Node class mappings + NODE_CLASS_MAPPINGS['VideoGenKlingNode']=VideoGenKlingNode + NODE_CLASS_MAPPINGS['VideoGenRunwayGen3Node']=VideoGenRunwayGen3Node + NODE_CLASS_MAPPINGS['VideoGenLumaDreamMachineNode']=VideoGenLumaDreamMachineNode + NODE_CLASS_MAPPINGS['LoadVideoFromURL']=LoadVideoFromURL + + NODE_DISPLAY_NAME_MAPPINGS["VideoGenKlingNode"]= "Kling Video Generation @fal" + NODE_DISPLAY_NAME_MAPPINGS["VideoGenRunwayGen3Node"]= "Runway Gen3 Image-to-Video @fal" + NODE_DISPLAY_NAME_MAPPINGS["VideoGenLumaDreamMachineNode"]= "Luma Dream Machine @fal" + NODE_DISPLAY_NAME_MAPPINGS["LoadVideoFromURL"]= "Load Video from URL" + + +except Exception as e: + logging.info('FalVideo.available False' ) + logging.info('\033[93m -------------- \033[0m') diff --git a/nodes/FalVideo.py b/nodes/FalVideo.py new file mode 100644 index 0000000..4a60aeb --- /dev/null +++ b/nodes/FalVideo.py @@ -0,0 +1,332 @@ +# 修改自 https://github.com/gokayfem/ComfyUI-fal-API/blob/main/nodes/video_node.py +# image-to-video all in one + +import os,sys +import torch +from PIL import Image +import tempfile +import numpy as np +import requests +import cv2 +import subprocess +import importlib.util +python = sys.executable + +def is_installed(package, package_overwrite=None,auto_install=True): + is_has=False + try: + spec = importlib.util.find_spec(package) + is_has=spec is not None + except ModuleNotFoundError: + pass + + package = package_overwrite or package + + if spec is None: + if auto_install==True: + print(f"Installing {package}...") + # 清华源 -i https://pypi.tuna.tsinghua.edu.cn/simple + command = f'"{python}" -m pip install {package}' + + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=os.environ) + + is_has=True + + if result.returncode != 0: + print(f"Couldn't install\nCommand: {command}\nError code: {result.returncode}") + is_has=False + else: + print(package+'## OK') + + return is_has + + +try: + if is_installed('fal_client','fal-client')==True: + from fal_client import submit, upload_file +except: + print("#install fal-client error") + + +def upload_image(image): + try: + # Convert the image tensor to a numpy array + if isinstance(image, torch.Tensor): + image_np = image.cpu().numpy() + else: + image_np = np.array(image) + + # Ensure the image is in the correct format (H, W, C) + if image_np.ndim == 4: + image_np = image_np.squeeze(0) # Remove batch dimension if present + if image_np.ndim == 2: + image_np = np.stack([image_np] * 3, axis=-1) # Convert grayscale to RGB + elif image_np.shape[0] == 3: + image_np = np.transpose(image_np, (1, 2, 0)) # Change from (C, H, W) to (H, W, C) + + # Normalize the image data to 0-255 range + if image_np.dtype == np.float32 or image_np.dtype == np.float64: + image_np = (image_np * 255).astype(np.uint8) + + # Convert to PIL Image + pil_image = Image.fromarray(image_np) + + # Save the image to a temporary file + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + pil_image.save(temp_file, format="PNG") + temp_file_path = temp_file.name + + # Upload the temporary file + image_url = upload_file(temp_file_path) + return image_url + except Exception as e: + print(f"Error uploading image: {str(e)}") + return None + finally: + # Clean up the temporary file + if 'temp_file_path' in locals(): + os.unlink(temp_file_path) + + +class VideoGenKlingNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"default": "", "multiline": True}), + "duration": (["5", "10"], {"default": "5"}), + "aspect_ratio": (["16:9", "9:16", "1:1"], {"default": "16:9"}), + "mode": (["standard", "pro"], {"default": "standard"}), + "fal_key":("STRING", {"forceInput": True,}), + }, + "optional": { + "image": ("IMAGE",), + }, + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "generate_video" + CATEGORY = "♾️Mixlab/Video" + + def generate_video(self, prompt, duration, aspect_ratio,mode,fal_key, image=None): + arguments = { + "prompt": prompt, + "duration": duration, + "aspect_ratio": aspect_ratio, + } + + os.environ["FAL_KEY"] = fal_key + + api_url="fal-ai/kling-video/v1/"+mode + + try: + if image is not None: + image_url = upload_image(image) + if image_url: + arguments["image_url"] = image_url + handler = submit(api_url+"/image-to-video", arguments=arguments) + else: + return ("Error: Unable to upload image.",) + else: + handler = submit(api_url+"/text-to-video", arguments=arguments) + + result = handler.get() + video_url = result["video"]["url"] + return (video_url,) + except Exception as e: + print(f"Error generating video: {str(e)}") + return ("Error: Unable to generate video.",) + + +class VideoGenRunwayGen3Node: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"default": "", "multiline": True}), + "image": ("IMAGE",), + "duration": (["5", "10"], {"default": "5"}), + "aspect_ratio": (["16:9", "9:16"], {"default": "16:9"}), + "fal_key":("STRING", {"forceInput": True,}), + }, + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "generate_video" + CATEGORY = "♾️Mixlab/Video" + + def generate_video(self, prompt, image, duration,aspect_ratio,fal_key): + os.environ["FAL_KEY"] = fal_key + try: + image_url = upload_image(image) + if not image_url: + return ("Error: Unable to upload image.",) + + arguments = { + "prompt": prompt, + "image_url": image_url, + "duration": duration, + "ratio":aspect_ratio + } + + handler = submit("fal-ai/runway-gen3/turbo/image-to-video", arguments=arguments) + result = handler.get() + video_url = result["video"]["url"] + return (video_url,) + except Exception as e: + print(f"Error generating video: {str(e)}") + return ("Error: Unable to generate video.",) + +class VideoGenLumaDreamMachineNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"default": "", "multiline": True}), + "aspect_ratio": (["16:9", "9:16", "4:3", "3:4", "21:9", "9:21"], {"default": "16:9"}), + "fal_key":("STRING", {"forceInput": True,}), + }, + "optional": { + "image": ("IMAGE",), + "loop": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "generate_video" + CATEGORY = "♾️Mixlab/Video" + + def generate_video(self, prompt, aspect_ratio,fal_key, image=None, loop=True): + + os.environ["FAL_KEY"] = fal_key + + arguments = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "loop": loop, + } + + try: + if image is not None: + image_url = upload_image(image) + if not image_url: + return ("Error: Unable to upload image.",) + arguments["image_url"] = image_url + endpoint = "fal-ai/luma-dream-machine/image-to-video" + else: + endpoint = "fal-ai/luma-dream-machine" + + handler = submit(endpoint, arguments=arguments) + result = handler.get() + video_url = result["video"]["url"] + return (video_url,) + except Exception as e: + print(f"Error generating video: {str(e)}") + return ("Error: Unable to generate video.",) + +class LoadVideoFromURL: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "url": ("STRING", {"default": "https://example.com/video.mp4"}), + "force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}), + "force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],), + "custom_width": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 8}), + "custom_height": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 8}), + "frame_load_cap": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}), + "skip_first_frames": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}), + "select_every_nth": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE", "INT", "VHS_VIDEOINFO") + RETURN_NAMES = ("frames", "frame_count", "video_info") + FUNCTION = "load_video_from_url" + CATEGORY = "♾️Mixlab/Video" + + def load_video_from_url(self, url, force_rate, force_size, custom_width, custom_height, frame_load_cap, skip_first_frames, select_every_nth): + # Download the video to a temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as temp_file: + response = requests.get(url, stream=True) + for chunk in response.iter_content(chunk_size=8192): + temp_file.write(chunk) + temp_file_path = temp_file.name + + # Load the video using OpenCV + cap = cv2.VideoCapture(temp_file_path) + + # Get video properties + fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + duration = total_frames / fps + + # Calculate target size + if force_size != "Disabled": + if force_size == "Custom Width": + new_height = int(height * (custom_width / width)) + new_width = custom_width + elif force_size == "Custom Height": + new_width = int(width * (custom_height / height)) + new_height = custom_height + elif force_size == "Custom": + new_width, new_height = custom_width, custom_height + else: + target_width, target_height = map(int, force_size.replace("?", "0").split("x")) + if target_width == 0: + new_width = int(width * (target_height / height)) + new_height = target_height + else: + new_height = int(height * (target_width / width)) + new_width = target_width + else: + new_width, new_height = width, height + + frames = [] + frame_count = 0 + + for i in range(total_frames): + ret, frame = cap.read() + if not ret: + break + + if i < skip_first_frames: + continue + + if (i - skip_first_frames) % select_every_nth != 0: + continue + + if force_size != "Disabled": + frame = cv2.resize(frame, (new_width, new_height)) + + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + frame = torch.from_numpy(frame).float() / 255.0 + frames.append(frame) + + frame_count += 1 + + if frame_load_cap > 0 and frame_count >= frame_load_cap: + break + + cap.release() + os.unlink(temp_file_path) + + frames = torch.stack(frames) + + video_info = { + "source_fps": fps, + "source_frame_count": total_frames, + "source_duration": duration, + "source_width": width, + "source_height": height, + "loaded_fps": fps if force_rate == 0 else force_rate, + "loaded_frame_count": frame_count, + "loaded_duration": frame_count / (fps if force_rate == 0 else force_rate), + "loaded_width": new_width, + "loaded_height": new_height, + } + + return (frames, frame_count, video_info) + diff --git a/pyproject.toml b/pyproject.toml index a307145..f9592de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-mixlab-nodes" description = "3D, ScreenShareNode & FloatingVideoNode, SpeechRecognition & SpeechSynthesis, GPT, LoadImagesFromLocal, Layers, Other Nodes, ..." -version = "0.45.0" +version = "0.46.0" license = "MIT" dependencies = ["numpy", "pyOpenSSL", "watchdog", "opencv-python-headless", "matplotlib", "openai", "simple-lama-inpainting", "clip-interrogator==0.6.0", "transformers>=4.36.0", "lark-parser", "imageio-ffmpeg", "rembg[gpu]", "omegaconf==2.3.0", "Pillow>=9.5.0", "einops==0.7.0", "trimesh>=4.0.5", "huggingface-hub", "scikit-image"] diff --git a/web/javascript/checkVersion_mixlab.js b/web/javascript/checkVersion_mixlab.js index 09fbaae..b171c60 100644 --- a/web/javascript/checkVersion_mixlab.js +++ b/web/javascript/checkVersion_mixlab.js @@ -3,7 +3,7 @@ import { app } from '../../../scripts/app.js' const repoOwner = 'shadowcz007' // 替换为仓库的所有者 const repoName = 'comfyui-mixlab-nodes' // 替换为仓库的名称 -const version = 'v0.45.0' +const version = 'v0.46.0' fetch(`https://api.github.com/repos/${repoOwner}/${repoName}/releases/latest`) .then(response => response.json()) diff --git a/workflow/video-all-in-one-test-workflow.json b/workflow/video-all-in-one-test-workflow.json new file mode 100644 index 0000000..d79b9e7 --- /dev/null +++ b/workflow/video-all-in-one-test-workflow.json @@ -0,0 +1,775 @@ +{ + "last_node_id": 18, + "last_link_id": 15, + "nodes": [ + { + "id": 6, + "type": "LoadImage", + "pos": [ + -32, + 79 + ], + "size": { + "0": 315, + "1": 314 + }, + "flags": {}, + "order": 0, + "mode": 0, + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1, + 2, + 3 + ], + "shape": 3, + "slot_index": 0 + }, + { + "name": "MASK", + "type": "MASK", + "links": null, + "shape": 3 + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "1.jpg", + "image" + ] + }, + { + "id": 7, + "type": "KeyInput", + "pos": [ + -34, + 585 + ], + "size": { + "0": 315, + "1": 94 + }, + "flags": {}, + "order": 1, + "mode": 0, + "outputs": [ + { + "name": "key", + "type": "STRING", + "links": [ + 4, + 5, + 6 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KeyInput" + }, + "widgets_values": [ + null, + null + ] + }, + { + "id": 10, + "type": "LoadVideoFromURL", + "pos": [ + 1038, + 21 + ], + "size": [ + 315, + 266 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "url", + "type": "STRING", + "link": 7, + "widget": { + "name": "url" + } + } + ], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 8 + ], + "shape": 3, + "slot_index": 0 + }, + { + "name": "frame_count", + "type": "INT", + "links": null, + "shape": 3 + }, + { + "name": "video_info", + "type": "VHS_VIDEOINFO", + "links": null, + "shape": 3 + } + ], + "properties": { + "Node name for S&R": "LoadVideoFromURL" + }, + "widgets_values": [ + "https://example.com/video.mp4", + 0, + "Disabled", + 512, + 512, + 0, + 0, + 1 + ] + }, + { + "id": 13, + "type": "LoadVideoFromURL", + "pos": [ + 1032, + 715 + ], + "size": { + "0": 315, + "1": 266 + }, + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "url", + "type": "STRING", + "link": 10, + "widget": { + "name": "url" + } + } + ], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 12 + ], + "shape": 3, + "slot_index": 0 + }, + { + "name": "frame_count", + "type": "INT", + "links": null, + "shape": 3 + }, + { + "name": "video_info", + "type": "VHS_VIDEOINFO", + "links": null, + "shape": 3 + } + ], + "properties": { + "Node name for S&R": "LoadVideoFromURL" + }, + "widgets_values": [ + "https://example.com/video.mp4", + 0, + "Disabled", + 512, + 512, + 0, + 0, + 1 + ] + }, + { + "id": 11, + "type": "PreviewImage", + "pos": [ + 1436, + 6 + ], + "size": [ + 210, + 246 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 8 + } + ], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 3, + "type": "VideoGenKlingNode", + "pos": [ + 527.4816383216086, + 19.100152539671797 + ], + "size": { + "0": 400, + "1": 200 + }, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + }, + { + "name": "fal_key", + "type": "STRING", + "link": 4, + "widget": { + "name": "fal_key" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 7, + 13 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VideoGenKlingNode" + }, + "widgets_values": [ + "The man is shaking his head with a wry smile.\n\n", + "5", + "16:9", + "standard", + "" + ] + }, + { + "id": 16, + "type": "ShowTextForGPT", + "pos": [ + 1702, + -85 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 13, + "widget": { + "name": "text" + } + }, + { + "name": "output_dir", + "type": "STRING", + "link": null, + "widget": { + "name": "output_dir" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null, + "shape": 6 + } + ], + "properties": { + "Node name for S&R": "ShowTextForGPT" + }, + "widgets_values": [ + "", + "", + "https://v2.fal.media/files/0f9f44093c7442d5b0819616880bca65_output.mp4" + ] + }, + { + "id": 4, + "type": "VideoGenRunwayGen3Node", + "pos": [ + 529, + 297 + ], + "size": { + "0": 400, + "1": 200 + }, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 2 + }, + { + "name": "fal_key", + "type": "STRING", + "link": 5, + "widget": { + "name": "fal_key" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 9, + 14 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VideoGenRunwayGen3Node" + }, + "widgets_values": [ + "The man is shaking his head with a wry smile.\n\n", + "5", + "16:9", + "" + ] + }, + { + "id": 5, + "type": "VideoGenLumaDreamMachineNode", + "pos": [ + 534, + 570 + ], + "size": { + "0": 400, + "1": 200 + }, + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 3 + }, + { + "name": "fal_key", + "type": "STRING", + "link": 6, + "widget": { + "name": "fal_key" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 10, + 15 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VideoGenLumaDreamMachineNode" + }, + "widgets_values": [ + "The man is shaking his head with a wry smile.\n\n", + "16:9", + "", + true + ] + }, + { + "id": 18, + "type": "ShowTextForGPT", + "pos": [ + 1719, + 665 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 15, + "widget": { + "name": "text" + } + }, + { + "name": "output_dir", + "type": "STRING", + "link": null, + "widget": { + "name": "output_dir" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null, + "shape": 6 + } + ], + "properties": { + "Node name for S&R": "ShowTextForGPT" + }, + "widgets_values": [ + "", + "", + "https://v2.fal.media/files/a622a5aac002452ba0e75f7d8871389d_output.mp4" + ] + }, + { + "id": 12, + "type": "LoadVideoFromURL", + "pos": [ + 1049, + 349 + ], + "size": { + "0": 315, + "1": 266 + }, + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "url", + "type": "STRING", + "link": 9, + "widget": { + "name": "url" + } + } + ], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 11 + ], + "shape": 3, + "slot_index": 0 + }, + { + "name": "frame_count", + "type": "INT", + "links": null, + "shape": 3 + }, + { + "name": "video_info", + "type": "VHS_VIDEOINFO", + "links": null, + "shape": 3 + } + ], + "properties": { + "Node name for S&R": "LoadVideoFromURL" + }, + "widgets_values": [ + "https://example.com/video.mp4", + 0, + "Disabled", + 512, + 512, + 0, + 0, + 1 + ] + }, + { + "id": 17, + "type": "ShowTextForGPT", + "pos": [ + 1711, + 318 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 14, + "widget": { + "name": "text" + } + }, + { + "name": "output_dir", + "type": "STRING", + "link": null, + "widget": { + "name": "output_dir" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": null, + "shape": 6 + } + ], + "properties": { + "Node name for S&R": "ShowTextForGPT" + }, + "widgets_values": [ + "", + "", + "https://v2.fal.media/files/755d51c8984a4445a852268a209b07e4_output.mp4" + ] + }, + { + "id": 15, + "type": "PreviewImage", + "pos": [ + 1444, + 650 + ], + "size": [ + 210, + 246 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 12 + } + ], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 14, + "type": "PreviewImage", + "pos": [ + 1416, + 310 + ], + "size": [ + 210, + 246 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 11 + } + ], + "properties": { + "Node name for S&R": "PreviewImage" + } + } + ], + "links": [ + [ + 1, + 6, + 0, + 3, + 0, + "IMAGE" + ], + [ + 2, + 6, + 0, + 4, + 0, + "IMAGE" + ], + [ + 3, + 6, + 0, + 5, + 0, + "IMAGE" + ], + [ + 4, + 7, + 0, + 3, + 1, + "STRING" + ], + [ + 5, + 7, + 0, + 4, + 1, + "STRING" + ], + [ + 6, + 7, + 0, + 5, + 1, + "STRING" + ], + [ + 7, + 3, + 0, + 10, + 0, + "STRING" + ], + [ + 8, + 10, + 0, + 11, + 0, + "IMAGE" + ], + [ + 9, + 4, + 0, + 12, + 0, + "STRING" + ], + [ + 10, + 5, + 0, + 13, + 0, + "STRING" + ], + [ + 11, + 12, + 0, + 14, + 0, + "IMAGE" + ], + [ + 12, + 13, + 0, + 15, + 0, + "IMAGE" + ], + [ + 13, + 3, + 0, + 16, + 0, + "STRING" + ], + [ + 14, + 4, + 0, + 17, + 0, + "STRING" + ], + [ + 15, + 5, + 0, + 18, + 0, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.7247295000000012, + "offset": [ + -59.526177135875514, + 197.10624904779425 + ] + } + }, + "version": 0.4 +} \ No newline at end of file