File indexing completed on 2025-01-05 04:00:26
0001 #!/usr/bin/env python3 0002 0003 import io 0004 import os 0005 import sys 0006 import json 0007 import signal 0008 import inspect 0009 import argparse 0010 import tempfile 0011 import traceback 0012 from xml.dom import minidom 0013 0014 from PyQt5 import QtSvg 0015 from PyQt5.Qsci import * 0016 from PyQt5.QtGui import * 0017 from PyQt5.QtCore import * 0018 from PyQt5.QtWidgets import * 0019 0020 0021 sys.path.insert(0, os.path.join( 0022 os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 0023 "lib" 0024 )) 0025 import lottie 0026 from lottie import gui, objects 0027 from lottie import __version__ 0028 from lottie.exporters.svg import export_svg 0029 from lottie.exporters.core import export_tgs 0030 from lottie.exporters.tgs_validator import TgsValidator 0031 0032 0033 class LottieViewerWindow(QMainWindow): 0034 def __init__(self): 0035 super().__init__() 0036 self.animation = None 0037 self.dirname = "" 0038 self._frame_cache = {} 0039 self._fu_gc = [] 0040 self.setWindowTitle("Lottie Viewer") 0041 0042 #self.setDockNestingEnabled(True) 0043 self.setDockOptions( 0044 QMainWindow.AnimatedDocks | 0045 QMainWindow.AllowNestedDocks | 0046 QMainWindow.AllowTabbedDocks | 0047 0 0048 ) 0049 0050 central_widget = QWidget() 0051 self.setCentralWidget(central_widget) 0052 self.layout = QVBoxLayout() 0053 central_widget.setLayout(self.layout) 0054 0055 menu = self.menuBar() 0056 file_menu = menu.addMenu("&File") 0057 file_toolbar = self.addToolBar("File") 0058 ks = QKeySequence 0059 for action in [ 0060 ("&Open...", "document-open", ks.Open, self.dialog_open_file), 0061 ("&Save", "document-save", ks.Save, self.save_code, "action_save"), 0062 ("Save &As...", "document-save-as", ks.SaveAs, self.dialog_save_as), 0063 ("&Refresh", "document-revert", ks.Refresh, self.reload_document), 0064 ("A&uto Refresh", "view-refresh", None, None, "action_auto_refresh"), 0065 ("&Quit", "application-exit", ks.Quit, self.close), 0066 ]: 0067 self._make_action(file_menu, file_toolbar, *action) 0068 0069 document_menu = menu.addMenu("&Document") 0070 document_toolbar = self.addToolBar("Document") 0071 for action in [ 0072 ("&Sanitize TGS", "transform-scale", None, self.tgs_sanitize), 0073 ("&Validate TGS", "document-edit-verify", None, self.tgs_check), 0074 ]: 0075 self._make_action(document_menu, document_toolbar, *action) 0076 0077 self.action_auto_refresh.setCheckable(True) 0078 self.action_auto_refresh.setChecked(True) 0079 self.action_save.setEnabled(False) 0080 0081 self.view_menu = menu.addMenu("&View") 0082 0083 self.tree_widget = QTreeWidget() 0084 self.tree_widget.setColumnCount(2) 0085 self.tree_widget.setHeaderLabels(["Property", "Value"]) 0086 self.tree_widget.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) 0087 self.dock_tree = self._dock("Properties", self.tree_widget, Qt.LeftDockWidgetArea, Qt.RightDockWidgetArea) 0088 0089 layout_display = QHBoxLayout() 0090 self.layout.addLayout(layout_display) 0091 self.display = QtSvg.QSvgWidget() 0092 layout_display.addWidget(self.display) 0093 self.display.setFixedSize(512, 512) 0094 self.display.setAutoFillBackground(True) 0095 palette = self.display.palette() 0096 palette.setBrush( 0097 self.display.backgroundRole(), 0098 QBrush(QColor(255, 255, 255)) 0099 #QBrush(QColor(128, 128, 128), Qt.Dense7Pattern) 0100 ) 0101 self.display.setPalette(palette) 0102 0103 self.widget_time = gui.timeline_widget.TimelineWidget() 0104 self.widget_time.frame_changed.connect(self._update_frame) 0105 self.widget_time.setEnabled(False) 0106 self._dock("Timeline", self.widget_time, Qt.TopDockWidgetArea, Qt.TopDockWidgetArea) 0107 0108 self._init_json_editor() 0109 code_menu = menu.addMenu("&Code") 0110 code_toolbar = self.addToolBar("Code") 0111 toggle_action = self.dock_json.toggleViewAction() 0112 toggle_action.setIcon(QIcon.fromTheme("document-edit")) 0113 code_toolbar.addAction(toggle_action) 0114 0115 apply_key = QKeySequence("Ctrl+B", QKeySequence.PortableText) 0116 for action in [ 0117 ("&Apply", "system-run", apply_key, self.apply_json), 0118 ("&Code", "code-context", None, self.toggle_code, "action_code_mode"), 0119 ]: 0120 self._make_action(code_menu, code_toolbar, *action) 0121 0122 self.console = gui.console.Console() 0123 self.console.set_font(self.code_font) 0124 self.console.lines.setPlainText(inspect.cleandoc(""" 0125 Lottie Python Console 0126 ===================== 0127 0128 Available objects: 0129 * lottie - The python-lottie module 0130 * document - The currently open animation 0131 * window - The GUI window object 0132 * refresh() - Reload the animation (use if you modify document) 0133 """)) 0134 dock_console = self._dock( 0135 "Console", self.console, Qt.BottomDockWidgetArea, 0136 Qt.TopDockWidgetArea|Qt.LeftDockWidgetArea|Qt.RightDockWidgetArea 0137 ) 0138 dock_console.hide() 0139 0140 toggle_action = dock_console.toggleViewAction() 0141 toggle_action.setIcon(QIcon.fromTheme("utilities-terminal")) 0142 self.console.define("lottie", lottie) 0143 self.console.define("window", self) 0144 self.console.define("refresh", self._console_refresh) 0145 code_toolbar.addAction(toggle_action) 0146 0147 self.action_code_mode.setCheckable(True) 0148 self.action_code_mode.setEnabled(False) 0149 self.code_mode = None 0150 0151 self._old_load = objects.Animation.load 0152 objects.Animation.load = self._new_load 0153 0154 self.label_cahed = QLabel() 0155 self.statusBar().addPermanentWidget(self.label_cahed) 0156 0157 self.fs_watcher = QFileSystemWatcher() 0158 self.fs_watcher.fileChanged.connect(self.maybe_reload) 0159 0160 self.filename = "" 0161 0162 def _init_json_editor(self): 0163 self.edit_json = QsciScintilla() 0164 self.edit_json.setUtf8(True) 0165 self.edit_json.setIndentationGuides(True) 0166 self.edit_json.setIndentationsUseTabs(False) 0167 self.edit_json.setTabWidth(4) 0168 self.edit_json.setTabIndents(True) 0169 self.edit_json.setAutoIndent(True) 0170 self.edit_json.setMarginType(0, QsciScintilla.NumberMargin) 0171 self.edit_json.setMarginWidth(0, "0000") 0172 self.edit_json.setFolding(QsciScintilla.BoxedTreeFoldStyle) 0173 0174 self.code_font = QFont("monospace", 10) 0175 self.code_font.setStyleHint(QFont.Monospace) 0176 self.edit_json.setFont(self.code_font) 0177 0178 self.lexer_svg = QsciLexerXML() 0179 self.lexer_svg.setDefaultFont(self.code_font) 0180 0181 self.lexer_py = QsciLexerPython() 0182 self.lexer_py.setDefaultFont(self.code_font) 0183 self.lexer_py.setFont(self.code_font) 0184 0185 self.lexer_json = QsciLexerJSON() 0186 self.lexer_json.setDefaultFont(self.code_font) 0187 0188 #self.completer_py = PyParser(self.lexer_py) 0189 self.completer_py = QsciAPIs(self.lexer_py) 0190 self.completer_py.prepare() 0191 self.edit_json.setAutoCompletionThreshold(3) 0192 self.edit_json.setAutoCompletionSource(QsciScintilla.AcsAll) 0193 0194 self.edit_json.setLexer(self.lexer_json) 0195 0196 commands = self.edit_json.standardCommands() 0197 0198 def shortcut(key): 0199 command = commands.boundTo(key) 0200 if command is not None: 0201 command.setKey(0) 0202 return QShortcut(key, self.edit_json).activated 0203 0204 shortcut(Qt.ControlModifier | Qt.ShiftModifier | Qt.Key_Up).connect(lambda: self._edit_move_line(-1)) 0205 shortcut(Qt.ControlModifier | Qt.ShiftModifier | Qt.Key_Down).connect(lambda: self._edit_move_line(+1)) 0206 0207 wrapper = QWidget() 0208 layout = QVBoxLayout() 0209 layout.setContentsMargins(0, 0, 0, 0) 0210 wrapper.setLayout(layout) 0211 layout.addWidget(self.edit_json) 0212 self.search_widget = gui.search_widget.SearchWidget(self.edit_json) 0213 layout.addWidget(self.search_widget) 0214 shortcut(Qt.ControlModifier | Qt.Key_F).connect(lambda: self.search_widget.start_search(False)) 0215 shortcut(Qt.ControlModifier | Qt.Key_R).connect(lambda: self.search_widget.start_search(True)) 0216 0217 self.dock_json = self._dock("Json", wrapper, Qt.RightDockWidgetArea, Qt.LeftDockWidgetArea) 0218 self.dock_json.hide() 0219 self._json_dump = "" 0220 0221 def _edit_move_line(self, down): 0222 line_start, ls_col, line_end, le_col = self.edit_json.getSelection() 0223 move_end = line_end 0224 0225 if line_start == -1: 0226 line_start, ls_col = self.edit_json.getCursorPosition() 0227 move_end = line_end = line_start 0228 le_col = ls_col 0229 elif le_col == 0: 0230 move_end -= 1 0231 0232 lines = self.edit_json.text().splitlines() 0233 0234 if down == -1: 0235 if line_start <= 0: 0236 return 0237 lines_before = lines[:line_start-1] 0238 line_before = lines[line_start-1] 0239 lines_moved = lines[line_start:move_end+1] 0240 lines_after = lines[move_end+1:] 0241 0242 lines_rearranged = lines_before + lines_moved + [line_before] + lines_after 0243 else: 0244 if line_end >= len(lines) - 1: 0245 return 0246 0247 lines_before = lines[:line_start] 0248 lines_moved = lines[line_start:move_end+1] 0249 line_after = lines[move_end+1] 0250 lines_after = lines[move_end+2:] 0251 0252 lines_rearranged = lines_before + [line_after] + lines_moved + lines_after 0253 0254 self.edit_json.setText("\n".join(lines_rearranged)) 0255 0256 if line_start == line_end and le_col == ls_col: 0257 self.edit_json.setCursorPosition(line_end+down, le_col) 0258 else: 0259 self.edit_json.setSelection(line_start+down, ls_col, line_end+down, le_col) 0260 0261 def _new_load(self, json_dict): 0262 # This hack is to ensure we display the original JSON when available 0263 json_dump = json.dumps(json_dict, indent=" "*4) 0264 if json_dump != self._json_dump: 0265 self._json_dump = json_dump 0266 if not self.action_code_mode.isChecked(): 0267 self.edit_json.setText(self._json_dump) 0268 return self._old_load(json_dict) 0269 0270 def _dock(self, name, widget, start_area, other_areas): 0271 dock = QDockWidget(name, self) 0272 dock.setAllowedAreas(start_area | other_areas) 0273 self.addDockWidget(start_area, dock) 0274 dock.setWidget(widget) 0275 self.view_menu.addAction(dock.toggleViewAction()) 0276 return dock 0277 0278 def _make_action(self, menu, toolbar, name, theme, key_sequence, trigger, attname=None): 0279 action = QAction(QIcon.fromTheme(theme), name, self) 0280 if key_sequence: 0281 action.setShortcut(key_sequence) 0282 if trigger: 0283 action.triggered.connect(trigger) 0284 0285 if attname: 0286 setattr(self, attname, action) 0287 0288 menu.addAction(action) 0289 toolbar.addAction(action) 0290 return action 0291 0292 def dialog_open_file(self): 0293 file_name, importer = gui.import_export.get_open_filename( 0294 self, "Open Animation", self.dirname 0295 ) 0296 0297 if file_name: 0298 self.open_file(file_name, importer) 0299 0300 def dialog_save_as(self): 0301 file_name, exporter = gui.import_export.get_save_filename( 0302 self, "Open Animation", self.dirname 0303 ) 0304 0305 if not file_name: 0306 return 0307 0308 options = exporter.prompt_options(self) 0309 if options is None: 0310 return 0311 0312 if not os.path.splitext(file_name)[1]: 0313 file_name += "." + exporter.exporter.extensions[0] 0314 0315 thread = gui.import_export.start_export(self, exporter.exporter, self.animation, file_name, options) 0316 self._fu_gc.append(thread) 0317 0318 def close_file(self): 0319 self._clear() 0320 self.setWindowTitle("Lottie Viewer") 0321 if self.filename: 0322 self.fs_watcher.removePath(self.filename) 0323 0324 self.importer = None 0325 self.importer_options = None 0326 self.filename = "" 0327 self.edit_json.setText("") 0328 self._json_dump = "" 0329 self.clear_code_mode() 0330 0331 def clear_code_mode(self): 0332 self.code_mode = None 0333 self.action_save.setEnabled(False) 0334 self.action_code_mode.setEnabled(False) 0335 self.action_code_mode.setChecked(False) 0336 self.edit_json.setLexer(self.lexer_json) 0337 0338 def _clear(self): 0339 self.widget_time.stop() 0340 self.animation = None 0341 self.console.define("document", None) 0342 self.widget_time.setEnabled(False) 0343 self.widget_time.stop() 0344 self._frame_cache = {} 0345 self.tree_widget.clear() 0346 0347 def open_file(self, file_name, importer, options=None): 0348 if options is None: 0349 options = importer.prompt_options(self) 0350 if options is None: 0351 return 0352 0353 self.close_file() 0354 animation = importer.importer.process(file_name, **options) 0355 self._open_animation(animation) 0356 self.setWindowTitle("Lottie Viewer - %s" % os.path.basename(file_name)) 0357 self.dirname = os.path.dirname(file_name) 0358 self.importer = importer 0359 self.importer_options = options 0360 self.filename = file_name 0361 self.fs_watcher.addPath(self.filename) 0362 0363 ext = os.path.splitext(file_name)[1][1:] 0364 if ext in ["svg", "py"]: 0365 self.code_mode = ext 0366 self.action_code_mode.setEnabled(True) 0367 if ext == "svg": 0368 self._code_dump = minidom.parse(file_name).toprettyxml(indent=" ") 0369 else: 0370 with open(file_name) as f: 0371 self._code_dump = f.read() 0372 elif ext == "json": 0373 self.action_save.setEnabled(True) 0374 0375 def _open_animation(self, animation): 0376 self.widget_time.set_min_max(animation.in_point, animation.out_point) 0377 self.widget_time.set_frame(animation.in_point) 0378 self.widget_time.fps = animation.frame_rate 0379 self.animation = animation 0380 self.display.setFixedSize(int(self.animation.width), int(self.animation.height)) 0381 gui.tree_view.lottie_to_tree(self.tree_widget, animation) 0382 self._update_frame() 0383 self.widget_time.setEnabled(True) 0384 self.console.define("document", self.animation) 0385 0386 if self._json_dump == "": 0387 self._json_dump = json.dumps(animation.to_dict(), indent=" ") 0388 self.edit_json.setText(self._json_dump) 0389 0390 def reload_document(self): 0391 if self.animation: 0392 self.open_file(self.filename, self.importer, self.importer_options) 0393 0394 def _update_frame(self): 0395 if not self.animation: 0396 return 0397 0398 rendered = self._render_frame(self.widget_time.frame) 0399 self.display.load(rendered) 0400 0401 def _render_frame(self, frame): 0402 if frame in self._frame_cache: 0403 return self._frame_cache[frame] 0404 0405 file = io.StringIO() 0406 export_svg(self.animation, file, frame) 0407 rendered = file.getvalue().encode("utf-8") 0408 self._frame_cache[frame] = rendered 0409 self.label_cahed.setText("%d%% Frames Rendered" % ( 0410 len(self._frame_cache) / ((self.animation.out_point+1) - self.animation.in_point) * 100 0411 )) 0412 return rendered 0413 0414 def maybe_reload(self, filename): 0415 if filename == self.filename and self.action_auto_refresh.isChecked(): 0416 self.open_file(self.filename, self.importer, self.importer_options) 0417 0418 def tgs_check(self): 0419 if not self.animation: 0420 return 0421 0422 validator = TgsValidator() 0423 validator(self.animation) 0424 bio = io.BytesIO() 0425 export_tgs(self.animation, bio, False, False) 0426 validator.check_size(len(bio.getvalue()), "exported") 0427 if validator.errors: 0428 QMessageBox.warning( 0429 self, 0430 "TGS Validation Issues", 0431 "\n".join(map(str, validator.errors))+"\n", 0432 QMessageBox.Ok 0433 ) 0434 else: 0435 QMessageBox.information( 0436 self, 0437 "TGS Validation Issues", 0438 "No issues found", 0439 QMessageBox.Ok 0440 ) 0441 0442 def tgs_sanitize(self): 0443 if not self.animation: 0444 return 0445 0446 self.animation.tgs_sanitize() 0447 self._console_refresh() 0448 0449 def apply_json(self): 0450 try: 0451 if self.code_mode and self.action_code_mode.isChecked(): 0452 with tempfile.NamedTemporaryFile("w") as file: 0453 file.write(self.edit_json.text()) 0454 file.flush() 0455 animation = self.importer.importer.process(file.name, **self.importer_options) 0456 else: 0457 self._json_dump = self.edit_json.text() 0458 animation = objects.Animation.load(json.loads(self._json_dump)) 0459 except Exception: 0460 traceback.print_exc() 0461 QMessageBox.warning(self, "Error", "Applying changes failed") 0462 return 0463 0464 self._clear() 0465 self._open_animation(animation) 0466 0467 def toggle_code(self): 0468 self.action_save.setEnabled(False) 0469 if not self.animation or not self.code_mode: 0470 return 0471 0472 if self.action_code_mode.isChecked(): 0473 self._json_dump = self.edit_json.text() 0474 self.action_auto_refresh.setChecked(False) 0475 self.action_save.setEnabled(True) 0476 if not self.dock_json.isVisible(): 0477 self.dock_json.show() 0478 self.edit_json.setLexer(getattr(self, "lexer_" + self.code_mode)) 0479 self.edit_json.setText(self._code_dump) 0480 else: 0481 self._code_dump = self.edit_json.text() 0482 self.edit_json.setLexer(self.lexer_json) 0483 self.edit_json.setText(self._json_dump) 0484 0485 def save_code(self): 0486 with open(self.filename, "w") as outfile: 0487 outfile.write(self.edit_json.text()) 0488 0489 def _console_refresh(self): 0490 self._json_dump = "" 0491 animation = self.animation 0492 self._clear() 0493 self._open_animation(animation) 0494 0495 0496 parser = argparse.ArgumentParser(description="GUI viewer for lottie Animations") 0497 parser.add_argument( 0498 "file", 0499 help="File to open", 0500 default=None, 0501 nargs="?" 0502 ) 0503 parser.add_argument("--version", "-v", action="version", version="%(prog)s - python-lottie " + __version__) 0504 0505 if __name__ == "__main__": 0506 ns = parser.parse_args() 0507 0508 signal.signal(signal.SIGINT, signal.SIG_DFL) 0509 0510 app = QApplication([]) 0511 0512 gui.import_export.GuiProgressReporter.set_global() 0513 0514 window = LottieViewerWindow() 0515 window.resize(1024, 800) 0516 window.show() 0517 if ns.file: 0518 importer = gui.import_export.gui_importer_from_filename(ns.file) 0519 window.open_file(ns.file, importer) 0520 0521 sys.exit(app.exec_())