moved thumbnail generation to plugin; extended plugin loading for pre and post window loading

This commit is contained in:
itdominator 2024-06-29 21:37:44 -05:00
parent 2f954f4c79
commit ce00970171
40 changed files with 359 additions and 125 deletions

View File

@ -14,6 +14,7 @@ class Manifest:
'ui_target': "plugin_control_list",
'pass_fm_events': "true"
}
pre_launch: bool = False
```

View File

@ -8,6 +8,7 @@
"ui_target": "plugin_control_list",
"pass_fm_events": "true",
"bind_keys": ["Example Plugin||send_message:<Control>f"]
}
},
"pre_launch": "false"
}
}

View File

@ -0,0 +1,3 @@
"""
Pligin Module
"""

View File

@ -0,0 +1,3 @@
"""
Pligin Package
"""

View File

@ -0,0 +1,73 @@
# Python imports
import json
import os
from os import path
# Lib imports
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
# Application imports
from .icon import Icon
class IconController(Icon):
def __init__(self):
CURRENT_PATH = os.path.dirname(os.path.realpath(__file__))
# NOTE: app_name should be defined using python 'builtins' and so too must be logger used in the various classes
app_name_exists = False
try:
app_name
app_name_exists = True
except Exception as e:
...
APP_CONTEXT = f"{app_name.lower()}" if app_name_exists else "shellfm"
USR_APP_CONTEXT = f"/usr/share/{APP_CONTEXT}"
USER_HOME = path.expanduser('~')
CONFIG_PATH = f"{USER_HOME}/.config/{APP_CONTEXT}"
self.DEFAULT_ICONS = f"{CONFIG_PATH}/icons"
self.DEFAULT_ICON = f"{self.DEFAULT_ICONS}/text.png"
self.FFMPG_THUMBNLR = f"{CONFIG_PATH}/ffmpegthumbnailer" # Thumbnail generator binary
self.BLENDER_THUMBNLR = f"{CONFIG_PATH}/blender-thumbnailer" # Blender thumbnail generator binary
self.ICON_DIRS = ["/usr/share/icons", f"{USER_HOME}/.icons" "/usr/share/pixmaps"]
self.BASE_THUMBS_PTH = f"{USER_HOME}/.thumbnails"
self.ABS_THUMBS_PTH = f"{self.BASE_THUMBS_PTH}/normal"
self.STEAM_ICONS_PTH = f"{self.BASE_THUMBS_PTH}/steam_icons"
if not path.isdir(self.BASE_THUMBS_PTH):
os.mkdir(self.BASE_THUMBS_PTH)
if not path.isdir(self.ABS_THUMBS_PTH):
os.mkdir(self.ABS_THUMBS_PTH)
if not path.isdir(self.STEAM_ICONS_PTH):
os.mkdir(self.STEAM_ICONS_PTH)
if not os.path.exists(self.DEFAULT_ICONS):
self.DEFAULT_ICONS = f"{USR_APP_CONTEXT}/icons"
self.DEFAULT_ICON = f"{self.DEFAULT_ICONS}/text.png"
CONFIG_FILE = f"{CURRENT_PATH}/../settings.json"
with open(CONFIG_FILE) as f:
settings = json.load(f)
config = settings["config"]
self.container_icon_wh = config["container_icon_wh"]
self.video_icon_wh = config["video_icon_wh"]
self.sys_icon_wh = config["sys_icon_wh"]
# Filters
filters = settings["filters"]
self.fmeshs = tuple(filters["meshs"])
self.fcode = tuple(filters["code"])
self.fvideos = tuple(filters["videos"])
self.foffice = tuple(filters["office"])
self.fimages = tuple(filters["images"])
self.ftext = tuple(filters["text"])
self.fmusic = tuple(filters["music"])
self.fpdf = tuple(filters["pdf"])

View File

@ -14,4 +14,4 @@ class MeshsIconMixin:
proc = subprocess.Popen([self.BLENDER_THUMBNLR, full_path, hash_img_path])
proc.wait()
except Exception as e:
self.logger.debug(repr(e))
logger.debug(repr(e))

View File

@ -14,7 +14,7 @@ class VideoIconMixin:
proc = subprocess.Popen([self.FFMPG_THUMBNLR, "-t", scrub_percent, "-s", "300", "-c", "jpg", "-i", full_path, "-o", hash_img_path])
proc.wait()
except Exception as e:
self.logger.debug(repr(e))
logger.info(repr(e))
self.ffprobe_generate_video_thumbnail(full_path, hash_img_path)
@ -51,5 +51,4 @@ class VideoIconMixin:
proc.wait()
except Exception as e:
print("Video thumbnail generation issue in thread:")
print( repr(e) )
self.logger.debug(repr(e))
logger.info(repr(e))

View File

@ -0,0 +1,12 @@
{
"manifest": {
"name": "Thumbnailer",
"author": "ITDominator",
"version": "0.0.1",
"support": "",
"requests": {
"pass_fm_events": "true"
},
"pre_launch": "true"
}
}

View File

@ -0,0 +1,59 @@
# Python imports
import os
# Lib imports
# Application imports
from plugins.plugin_base import PluginBase
from .icons.controller import IconController
class Plugin(PluginBase):
def __init__(self):
super().__init__()
self.name = "Thumbnailer" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus
# where self.name should not be needed for message comms
# self.path = os.path.dirname(os.path.realpath(__file__))
def run(self):
self.icon_controller = IconController()
self._event_system.subscribe("create-thumbnail", self.create_thumbnail)
def generate_reference_ui_element(self):
...
def create_thumbnail(self, dir, file) -> str:
return self.icon_controller.create_icon(dir, file)
def get_video_icons(self, dir) -> list:
data = []
def get_video_icons(self) -> list:
data = []
fvideos = self.icon_controller.fvideos
vids = [ file for file in os.path.list_dir(dir) if file.lower().endswith(fvideos) ]
for file in vids:
img_hash, hash_img_path = self.create_video_thumbnail(full_path = f"{dir}/{file}", returnHashInstead = True)
data.append([img_hash, hash_img_path])
return data
def get_pixbuf_icon_str_combo(self, dir) -> list:
data = []
for file in os.path.list_dir(dir):
icon = self.icon_controller.create_icon(dir, file).get_pixbuf()
data.append([icon, file])
return data
def get_gtk_icon_str_combo(self, dir) -> list:
data = []
for file in os.path.list_dir(dir):
icon = self.icon_controller.create_icon(dir, file)
data.append([icon, file[0]])
return data

View File

@ -0,0 +1,101 @@
{
"config":{
"thumbnailer_path":"ffmpegthumbnailer",
"blender_thumbnailer_path":"",
"container_icon_wh":[
128,
128
],
"video_icon_wh":[
128,
64
],
"sys_icon_wh":[
56,
56
],
"steam_cdn_url":"https://steamcdn-a.akamaihd.net/steam/apps/",
"remux_folder_max_disk_usage":"8589934592"
},
"filters":{
"meshs":[
".dae",
".fbx",
".gltf",
".obj",
".stl"
],
"code":[
".cpp",
".css",
".c",
".go",
".html",
".htm",
".java",
".js",
".json",
".lua",
".md",
".py",
".rs",
".toml",
".xml",
".pom"
],
"videos":[
".mkv",
".mp4",
".webm",
".avi",
".mov",
".m4v",
".mpg",
".mpeg",
".wmv",
".flv"
],
"office":[
".doc",
".docx",
".xls",
".xlsx",
".xlt",
".xltx",
".xlm",
".ppt",
".pptx",
".pps",
".ppsx",
".odt",
".rtf"
],
"images":[
".png",
".jpg",
".jpeg",
".gif",
".ico",
".tga",
".webp"
],
"text":[
".txt",
".text",
".sh",
".cfg",
".conf",
".log"
],
"music":[
".psf",
".mp3",
".ogg",
".flac",
".m4a"
],
"pdf":[
".pdf"
]
}
}

View File

@ -111,6 +111,8 @@ class Plugin(PluginBase):
for uri in state.uris:
self.trashman.trash(uri, verbocity)
self.trashman.regenerate()
def restore_trash_files(self, widget = None, eve = None, verbocity = False):
self._event_system.emit("get_current_state")
state = self._fm_state

View File

@ -43,4 +43,4 @@ class Trash(object):
def restore(self, filename, verbose):
"""Restore a file from trash."""
raise NotImplementedError(_('Backend didnt \ implement this functionality'))
raise NotImplementedError(_('Backend didnt implement this functionality'))

View File

@ -127,7 +127,7 @@ DeletionDate={}
f.write(infofile)
f.close()
self.regenerate()
# self.regenerate()
if verbose:
sys.stderr.write(_('trashed \'{}\'\n').format(filename))

View File

@ -44,10 +44,13 @@ class Controller(UIMixin, SignalsMixins, Controller_Data):
self._subscribe_to_events()
self._load_widgets()
if args.no_plugins == "false":
self.plugins_controller.pre_launch_plugins()
self._generate_file_views(self.fm_controller_data)
if args.no_plugins == "false":
self.plugins.launch_plugins()
self.plugins_controller.post_launch_plugins()
for arg in unknownargs + [args.new_tab,]:
if os.path.isdir(arg):
@ -116,7 +119,7 @@ class Controller(UIMixin, SignalsMixins, Controller_Data):
def reload_plugins(self, widget=None, eve=None):
self.plugins.reload_plugins()
self.plugins_controller.reload_plugins()
def do_action_from_menu_controls(self, _action=None, eve=None):
@ -196,4 +199,4 @@ class Controller(UIMixin, SignalsMixins, Controller_Data):
tab.execute([f"{tab.terminal_app}"], start_dir=tab.get_current_directory())
def go_to_path(self, path: str):
self.builder.get_object("path_entry").set_text(path)
self.builder.get_object("path_entry").set_text(path)

View File

@ -29,7 +29,7 @@ class Controller_Data:
self._load_glade_file()
self.fm_controller = WindowController()
self.plugins = PluginsController()
self.plugins_controller = PluginsController()
self.fm_controller_data = self.fm_controller.get_state_from_file()
self.window1 = self.builder.get_object("window_1")
@ -179,4 +179,4 @@ class Controller_Data:
proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE)
proc.stdin.write(data.encode("utf-8"))
proc.stdin.close()
retcode = proc.wait()
retcode = proc.wait()

View File

@ -31,7 +31,6 @@ class GridMixin:
# return
dir = tab.get_current_directory()
files = tab.get_files()
@ -167,4 +166,4 @@ class GridMixin:
icon_grid = obj.get_children()[0]
name = icon_grid.get_name()
if name == _name:
return icon_grid
return icon_grid

View File

@ -86,4 +86,4 @@ class UIMixin(PaneMixin, WindowMixin):
for j in range(0, 4):
i = j + 1
self.fm_controller.create_window()
self.create_new_tab_notebook(None, i, None)
self.create_new_tab_notebook(None, i, None)

View File

@ -15,32 +15,37 @@ class ManifestProcessorException(Exception):
...
@dataclass(slots=True)
@dataclass(slots = True)
class PluginInfo:
path: str = None
name: str = None
author: str = None
version: str = None
support: str = None
requests:{} = None
reference: type = None
path: str = None
name: str = None
author: str = None
version: str = None
support: str = None
requests:{} = None
reference: type = None
pre_launch: bool = False
class ManifestProcessor:
def __init__(self, path, builder):
manifest = join(path, "manifest.json")
if not os.path.exists(manifest):
manifest_pth = join(path, "manifest.json")
if not os.path.exists(manifest_pth):
raise ManifestProcessorException("Invalid Plugin Structure: Plugin doesn't have 'manifest.json'. Aboarting load...")
self._path = path
self._builder = builder
with open(manifest) as f:
with open(manifest_pth) as f:
data = json.load(f)
self._manifest = data["manifest"]
self._plugin = self.collect_info()
def is_pre_launch(self) -> bool:
return self._plugin.pre_launch
def collect_info(self) -> PluginInfo:
plugin = PluginInfo()
plugin.path = self._path
plugin.name = self._manifest["name"]
plugin.author = self._manifest["author"]
@ -48,6 +53,9 @@ class ManifestProcessor:
plugin.support = self._manifest["support"]
plugin.requests = self._manifest["requests"]
if "pre_launch" in self._manifest.keys():
plugin.pre_launch = True if self._manifest["pre_launch"] == "true" else False
return plugin
def get_loading_data(self):
@ -90,4 +98,4 @@ class ManifestProcessor:
if isinstance(requests["bind_keys"], list):
loading_data["bind_keys"] = requests["bind_keys"]
return self._plugin, loading_data
return self._plugin, loading_data

View File

@ -36,41 +36,76 @@ class PluginsController:
self._plugins_dir_watcher = None
self._plugin_collection = []
self._plugin_manifests = {}
self._load_manifests()
def launch_plugins(self) -> None:
def _load_manifests(self):
logger.info(f"Loading manifests...")
for path, folder in [[join(self._plugins_path, item), item] if os.path.isdir(join(self._plugins_path, item)) else None for item in os.listdir(self._plugins_path)]:
manifest = ManifestProcessor(path, self._builder)
self._plugin_manifests[path] = {
"path": path,
"folder": folder,
"manifest": manifest
}
self._set_plugins_watcher()
self.load_plugins()
def _set_plugins_watcher(self) -> None:
self._plugins_dir_watcher = Gio.File.new_for_path(self._plugins_path) \
.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, Gio.Cancellable())
self._plugins_dir_watcher.connect("changed", self._on_plugins_changed, ())
def _on_plugins_changed(self, file_monitor, file, other_file=None, eve_type=None, data=None):
def _on_plugins_changed(self, file_monitor, file, other_file = None, eve_type = None, data = None):
if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.DELETED,
Gio.FileMonitorEvent.RENAMED, Gio.FileMonitorEvent.MOVED_IN,
Gio.FileMonitorEvent.MOVED_OUT]:
self.reload_plugins(file)
@daemon_threaded
def load_plugins(self, file: str = None) -> None:
logger.info(f"Loading plugins...")
def pre_launch_plugins(self) -> None:
logger.info(f"Loading pre-launch plugins...")
plugin_manifests: {} = {}
for key in self._plugin_manifests:
target_manifest = self._plugin_manifests[key]["manifest"]
if target_manifest.is_pre_launch():
plugin_manifests[key] = self._plugin_manifests[key]
self._load_plugins(plugin_manifests, is_pre_launch = True)
def post_launch_plugins(self) -> None:
logger.info(f"Loading post-launch plugins...")
plugin_manifests: {} = {}
for key in self._plugin_manifests:
target_manifest = self._plugin_manifests[key]["manifest"]
if not target_manifest.is_pre_launch():
plugin_manifests[key] = self._plugin_manifests[key]
self._load_plugins(plugin_manifests)
def _load_plugins(self, plugin_manifests: {} = {}, is_pre_launch: bool = False) -> None:
parent_path = os.getcwd()
for path, folder in [[join(self._plugins_path, item), item] if os.path.isdir(join(self._plugins_path, item)) else None for item in os.listdir(self._plugins_path)]:
try:
target = join(path, "plugin.py")
manifest = ManifestProcessor(path, self._builder)
for key in plugin_manifests:
target_manifest = plugin_manifests[key]
path, folder, manifest = target_manifest["path"], target_manifest["folder"], target_manifest["manifest"]
try:
target = join(path, "plugin.py")
if not os.path.exists(target):
raise FileNotFoundError("Invalid Plugin Structure: Plugin doesn't have 'plugin.py'. Aboarting load...")
plugin, loading_data = manifest.get_loading_data()
module = self.load_plugin_module(path, folder, target)
GLib.idle_add(self.execute_plugin, *(module, plugin, loading_data))
# self.execute_plugin(module, plugin, loading_data)
if is_pre_launch:
self.execute_plugin(module, plugin, loading_data)
else:
GLib.idle_add(self.execute_plugin, *(module, plugin, loading_data))
except InvalidPluginException as e:
logger.info(f"Malformed Plugin: Not loading -->: '{folder}' !")
logger.debug("Trace: ", traceback.print_exc())
@ -128,4 +163,4 @@ class PluginsController:
os.chdir(plugin.path)
plugin.reference.reload_package(f"{plugin.path}/plugin.py")
os.chdir(parent_path)
os.chdir(parent_path)

View File

@ -14,7 +14,6 @@ from random import randint
from .utils.settings import Settings
from .utils.launcher import Launcher
from .utils.filehandler import FileHandler
from .icons.icon import Icon
from .path import Path
@ -40,9 +39,8 @@ except Exception as e:
class Tab(Settings, FileHandler, Launcher, Icon, Path):
class Tab(Settings, FileHandler, Launcher, Path):
def __init__(self):
self.logger = None
self._id_length: int = 10
self._id: str = ""
@ -168,33 +166,6 @@ class Tab(Settings, FileHandler, Launcher, Icon, Path):
}
}
def get_video_icons(self) -> list:
data = []
dir = self.get_current_directory()
for file in self._vids:
img_hash, hash_img_path = self.create_video_thumbnail(full_path=f"{dir}/{file}", returnHashInstead=True)
data.append([img_hash, hash_img_path])
return data
def get_pixbuf_icon_str_combo(self):
data = []
dir = self.get_current_directory()
for file in self._files:
icon = self.create_icon(dir, file).get_pixbuf()
data.append([icon, file])
return data
def get_gtk_icon_str_combo(self) -> list:
data = []
dir = self.get_current_directory()
for file in self._files:
icon = self.create_icon(dir, file)
data.append([icon, file[0]])
return data
def get_current_directory(self) -> str:
return self.get_path()
@ -264,7 +235,7 @@ class Tab(Settings, FileHandler, Launcher, Icon, Path):
return int(text) if text.isdigit() else text
def _natural_keys(self, text):
return [ self._atoi(c) for c in re.split('(\d+)',text) ]
return [ self._atoi(c) for c in re.split(r'(\d+)', text) ]
def _hash_text(self, text) -> str:
return hashlib.sha256(str.encode(text)).hexdigest()[:18]
@ -289,3 +260,7 @@ class Tab(Settings, FileHandler, Launcher, Icon, Path):
def _set_error_message(self, text: str):
self.error_message = text
def create_icon(self, dir, file):
return event_system.emit_and_await("create-thumbnail", (dir, file,))

View File

@ -41,11 +41,11 @@ class Launcher:
def execute(self, command, start_dir=os.getenv("HOME"), use_shell=False):
try:
self.logger.debug(command)
logger.debug(command)
subprocess.Popen(command, cwd=start_dir, shell=use_shell, start_new_session=True, stdout=None, stderr=None, close_fds=True)
except ShellFMLauncherException as e:
self.logger.error(f"Couldn't execute: {command}")
self.logger.error(e)
logger.error(f"Couldn't execute: {command}")
logger.error(e)
# TODO: Return std(out/in/err) handlers along with subprocess instead of sinking to null
def execute_and_return_thread_handler(self, command, start_dir=os.getenv("HOME"), use_shell=False):
@ -53,8 +53,8 @@ class Launcher:
DEVNULL = open(os.devnull, 'w')
return subprocess.Popen(command, cwd=start_dir, shell=use_shell, start_new_session=False, stdout=DEVNULL, stderr=DEVNULL, close_fds=False)
except ShellFMLauncherException as e:
self.logger.error(f"Couldn't execute and return thread: {command}")
self.logger.error(e)
logger.error(f"Couldn't execute and return thread: {command}")
logger.error(e)
return None
@threaded
@ -63,7 +63,7 @@ class Launcher:
def remux_video(self, hash, file):
remux_vid_pth = "{self.REMUX_FOLDER}/{hash}.mp4"
self.logger.debug(remux_vid_pth)
logger.debug(remux_vid_pth)
if not os.path.isfile(remux_vid_pth):
self.check_remux_space()
@ -83,8 +83,8 @@ class Launcher:
proc = subprocess.Popen(command)
proc.wait()
except ShellFMLauncherException as e:
self.logger.error(message)
self.logger.error(e)
logger.error(message)
logger.error(e)
return False
return True
@ -94,7 +94,7 @@ class Launcher:
try:
limit = int(limit)
except ShellFMLauncherException as e:
self.logger.debug(e)
logger.debug(e)
return
usage = self.get_remux_folder_usage(self.REMUX_FOLDER)
@ -113,4 +113,4 @@ class Launcher:
if not os.path.islink(fp): # Skip if it is symbolic link
total_size += os.path.getsize(fp)
return total_size
return total_size

View File

@ -14,8 +14,6 @@ class ShellFMSettingsException(Exception):
class Settings:
logger = None
# NOTE: app_name should be defined using python 'builtins'
app_name_exists = False
try:
@ -31,45 +29,13 @@ class Settings:
CONFIG_FILE = f"{CONFIG_PATH}/settings.json"
HIDE_HIDDEN_FILES = True
DEFAULT_ICONS = f"{CONFIG_PATH}/icons"
DEFAULT_ICON = f"{DEFAULT_ICONS}/text.png"
FFMPG_THUMBNLR = f"{CONFIG_PATH}/ffmpegthumbnailer" # Thumbnail generator binary
BLENDER_THUMBNLR = f"{CONFIG_PATH}/blender-thumbnailer" # Blender thumbnail generator binary
REMUX_FOLDER = f"{USER_HOME}/.remuxs" # Remuxed files folder
ICON_DIRS = ["/usr/share/icons", f"{USER_HOME}/.icons" "/usr/share/pixmaps"]
BASE_THUMBS_PTH = f"{USER_HOME}/.thumbnails"
ABS_THUMBS_PTH = f"{BASE_THUMBS_PTH}/normal"
STEAM_ICONS_PTH = f"{BASE_THUMBS_PTH}/steam_icons"
if not os.path.exists(CONFIG_PATH) or not os.path.exists(CONFIG_FILE):
msg = f"No config file located! Aborting loading ShellFM library...\nExpected: {CONFIG_FILE}"
raise ShellFMSettingsException(msg)
if not path.isdir(REMUX_FOLDER):
os.mkdir(REMUX_FOLDER)
if not path.isdir(BASE_THUMBS_PTH):
os.mkdir(BASE_THUMBS_PTH)
if not path.isdir(ABS_THUMBS_PTH):
os.mkdir(ABS_THUMBS_PTH)
if not path.isdir(STEAM_ICONS_PTH):
os.mkdir(STEAM_ICONS_PTH)
if not os.path.exists(DEFAULT_ICONS):
DEFAULT_ICONS = f"{USR_APP_CONTEXT}/icons"
DEFAULT_ICON = f"{DEFAULT_ICONS}/text.png"
with open(CONFIG_FILE) as f:
settings = json.load(f)
config = settings["config"]
subpath = config["base_of_home"]
STEAM_CDN_URL = config["steam_cdn_url"]
FFMPG_THUMBNLR = FFMPG_THUMBNLR if config["thumbnailer_path"] == "" else config["thumbnailer_path"]
BLENDER_THUMBNLR = BLENDER_THUMBNLR if config["blender_thumbnailer_path"] == "" else config["blender_thumbnailer_path"]
HIDE_HIDDEN_FILES = True if config["hide_hidden_files"] in ["true", ""] else False
go_past_home = True if config["go_past_home"] in ["true", ""] else False
lock_folder = False if config["lock_folder"] in ["false", ""] else True
@ -83,9 +49,6 @@ class Settings:
code_app = config["code_app"]
text_app = config["text_app"]
terminal_app = config["terminal_app"]
container_icon_wh = config["container_icon_wh"]
video_icon_wh = config["video_icon_wh"]
sys_icon_wh = config["sys_icon_wh"]
file_manager_app = config["file_manager_app"]
remux_folder_max_disk_usage = config["remux_folder_max_disk_usage"]

View File

@ -2,8 +2,6 @@
"config": {
"base_of_home": "",
"hide_hidden_files": "true",
"thumbnailer_path": "ffmpegthumbnailer",
"blender_thumbnailer_path": "",
"go_past_home": "true",
"lock_folder": "false",
"locked_folders": "venv::::flasks",
@ -16,11 +14,7 @@
"code_app": "newton",
"text_app": "mousepad",
"terminal_app": "terminator",
"container_icon_wh": [128, 128],
"video_icon_wh": [128, 64],
"sys_icon_wh": [56, 56],
"file_manager_app": "solarfm",
"steam_cdn_url": "https://steamcdn-a.akamaihd.net/steam/apps/",
"remux_folder_max_disk_usage": "8589934592",
"make_transparent":0,
"main_window_x":721,
@ -29,6 +23,9 @@
"main_window_min_height":480,
"main_window_width":800,
"main_window_height":600,
"application_dirs":[
"/usr/share/applications"
]
},
"filters": {
"meshs": [".dae", ".fbx", ".gltf", ".obj", ".stl"],
@ -49,4 +46,4 @@
"ch_log_lvl": 20,
"fh_log_lvl": 10
}
}
}