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_())