]> git.parisson.com Git - teleforma.git/commitdiff
Add MediaMarker model and kdenlive script
authorYoan Le Clanche <yoanl@pilotsystems.net>
Tue, 17 May 2022 14:00:20 +0000 (16:00 +0200)
committerYoan Le Clanche <yoanl@pilotsystems.net>
Tue, 17 May 2022 14:00:20 +0000 (16:00 +0200)
teleforma/admin.py
teleforma/migrations/0001_initial.py
teleforma/models/core.py
teleforma/utils/kdenlive.py [new file with mode: 0644]
teleforma/utils/xmltodict2.py [new file with mode: 0644]

index 35563c084875331802a24b3c4d7f55083b3c7fb4..4c3ed3be1f0a1d78f9b73a3583ddf14486d5d8b6 100644 (file)
@@ -139,12 +139,13 @@ class DocumentAdmin(admin.ModelAdmin):
 
 class MediaTranscodedInline(admin.TabularInline):
     model = MediaTranscoded
-
+class MediaItemMarkerInline(admin.StackedInline):
+    model = MediaItemMarker
 class MediaAdmin(admin.ModelAdmin):
     date_hierarchy = 'date_added'
     exclude = ['readers']
     search_fields = ['id', 'title', 'course__title', 'course__code', 'item__title']
-    inlines = [MediaTranscodedInline]
+    inlines = [MediaTranscodedInline, MediaItemMarkerInline]
 
 class ConferenceAdmin(admin.ModelAdmin):
     exclude = ['readers', 'keywords']
index d306292355babf8d48d0f65cf61caf74fd049d4b..71dc2e6f885026de281d60f272af2be9c3c13800 100644 (file)
@@ -493,6 +493,24 @@ class Migration(migrations.Migration):
                 'db_table': 'teleforma_media_transcoded',
             },
         ),
+        
+        migrations.CreateModel(
+            name='MediaItemMarker',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('public_id', models.CharField(max_length=255, verbose_name='public_id')),
+                ('time', models.FloatField(blank=True, null=True, verbose_name='time (s)')),
+                ('title', models.CharField(blank=True, null=True, max_length=255, verbose_name='title')),
+                ('date', models.DateTimeField(auto_now=True, blank=True, null=True, verbose_name='date')),
+                ('description', models.TextField(verbose_name='description', blank=True, null=True)),
+                ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='markers', to='teleforma.media', verbose_name='item')),
+                ('author', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name="markers", verbose_name='author', blank=True, null=True, on_delete=models.SET_NULL)),
+            ],
+            options={
+                'db_table': 'teleforma_media_markers', 
+                'ordering': ['time']
+            },
+        ),
         migrations.AddField(
             model_name='media',
             name='period',
index 82e396d57b486051df4e44d742126e967fa6d412..a0cf7dc46d9bf2b9e6e4c46c83a108adf5c7938f 100755 (executable)
@@ -556,6 +556,29 @@ class MediaTranscoded(Model):
     class Meta(MetaCore):
         db_table = app_label + '_media_transcoded'
 
+class MediaItemMarker(Model):
+    "2D marker object : text value vs. time (in seconds)"
+
+    element_type = 'marker'
+
+    item            = ForeignKey('Media', related_name="markers", verbose_name=_('item'), on_delete=models.CASCADE)
+    public_id       = CharField(_('public_id'), max_length=255)
+    time            = FloatField(_('time (s)'), blank=True, null=True)
+    title           = CharField(_('title'), blank=True, null=True, max_length=255)
+    date            = DateTimeField(_('date'), auto_now=True, blank=True, null=True)
+    description     = TextField(_('description'), blank=True, null=True)
+    author          = ForeignKey(User, related_name="markers", verbose_name=_('author'),
+                                 blank=True, null=True, on_delete=models.SET_NULL)
+
+    class Meta(MetaCore):
+        db_table = app_label + '_media_markers'
+        ordering = ['time']
+
+    def __str__(self):
+        if self.title:
+            return self.title
+        else:
+            return self.public_id
 
 class Media(MediaBase):
     "Describe a media resource linked to a conference"
diff --git a/teleforma/utils/kdenlive.py b/teleforma/utils/kdenlive.py
new file mode 100644 (file)
index 0000000..043376e
--- /dev/null
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2012-2013 Parisson SARL
+
+# This software is a computer program whose purpose is to backup, analyse,
+# transcode and stream any audio content with its metadata over a web frontend.
+
+# This software is governed by the CeCILL  license under French law and
+# abiding by the rules of distribution of free software.  You can  use,
+# modify and/ or redistribute the software under the terms of the CeCILL
+# license as circulated by CEA, CNRS and INRIA at the following URL
+# "http://www.cecill.info".
+
+# As a counterpart to the access to the source code and  rights to copy,
+# modify and redistribute granted by the license, users are provided only
+# with a limited warranty  and the software's author,  the holder of the
+# economic rights,  and the successive licensors  have only  limited
+# liability.
+
+# In this respect, the user's attention is drawn to the risks associated
+# with loading,  using,  modifying and/or developing or reproducing the
+# software by the user in light of its specific status of free software,
+# that may mean  that it is complicated to manipulate,  and  that  also
+# therefore means  that it is reserved for developers  and  experienced
+# professionals having in-depth computer knowledge. Users are therefore
+# encouraged to load and test the software's suitability as regards their
+# requirements in conditions enabling the security of their systems and/or
+# data to be ensured and,  more generally, to use and operate it in the
+# same conditions as regards security.
+
+# The fact that you are presently reading this means that you have had
+# knowledge of the CeCILL license and that you accept its terms.
+
+# Authors: Guillaume Pellerin <yomguy@parisson.com>
+
+
+import time
+from .xmltodict2 import *
+
+
+class KDEnLiveSession(object):
+
+    def __init__(self, path):
+        self.session = xmltodict(path)
+
+    def entries(self):
+        entries = []
+        for attr in self.session['children']:
+            if 'playlist' in attr['name'] and 'children' in attr:
+                for att in attr['children']:
+                    if 'entry' in att['name'] and att['attributes']['producer'] != 'black':
+                        entries.append(att['attributes'])
+        return entries
+
+    def video_entries(self):
+        entries = []
+        for attr in self.session['children']:
+            if 'playlist' in attr['name'] and 'children' in attr and not 'main' in attr['attributes']['id']:
+                for att in attr['children']:
+                    if 'entry' in att['name'] and att['attributes']['producer'] != 'black' \
+                            and not 'audio' in att['attributes']['producer']:
+                        entries.append(att['attributes'])
+        return entries
+
+    def entries_sorted(self):
+        return sorted(self.entries(), key=lambda k: int(k['in']), reverse=False)
+
+    def entries_video_seconds(self):
+        profile = self.profile()
+        fps = float(profile['frame_rate_num'])/float(profile['frame_rate_den'])
+        #fps= 25
+        res = []
+        start = 0
+        entries = self.video_entries()
+
+        for entry in entries:
+            id = entry['producer'].split('_')[0]
+            t_in = int(entry['in'])/fps
+            t_out = int(entry['out'])/fps
+            t_len = t_out - t_in
+            end = start + t_len
+            res.append({ 'id': str(id), 't': start, 'in': t_in, 'out': t_out })
+            start = end
+        return res
+
+    def cuts(self, entries):
+        i = 0
+        cuts = [0, ]
+        for entry in entries:
+            if i > 0:
+                cuts.append(cuts[i-1] + int(entries[i]['in'])-int(entries[i-1]['out']))
+            i += 1
+        return cuts
+
+    def first_video_frame(self):
+        return int(self.entries_sorted()[0]['in'])
+
+    def profile(self):
+        for attr in self.session['children']:
+            if 'profile' in attr['name']:
+                return attr['attributes']
+
+    def fix_text(self, text):
+        try:
+            s = text.split(' ')
+            i = int(s[1])
+            s.insert(2, ':')
+            return ' '.join(s)
+        except:
+            return text
+
+    def markers(self, offset=0, from_first_marker=False):
+        """ by default return a dict of markers with timecodes relative to an origin
+
+            if from_first_marker=False: the origin is the first entry timecode
+            if from_first_marker=True: the origin is the first entry timecode before the first marker
+
+            offset: general origin offset
+        """
+
+        abs_time = 0
+        markers = []
+        i = 0
+        entries = self.entries_video_seconds()
+        title = ''
+
+        for attr in self.session['children']:
+            if 'playlist' in attr['name']:
+                for att in attr['children']:
+                    marker = {}
+                    if 'name' in att['attributes']:
+                        name = att['attributes']['name']
+                        if 'docmetadata.meta.attr.title.markup' in name:
+                            title = att['cdata']
+                        if 'marker' in name:
+                            name = name.encode('utf8')
+                            marker_time = float(name.split(':')[-1].replace(',','.').replace(' ', ''))
+                            id = str(name.split(':')[-2].split('.')[-1])
+                            rel_time = 0
+
+                            for entry in entries:
+                                if entry['in'] <= marker_time <= entry['out'] and id == entry['id']:
+                                    if i == 0 and from_first_marker:
+                                        abs_time = entry['t']
+                                    rel_time = entry['t'] + (marker_time - entry['in']) - abs_time + offset
+                                    break
+                            else:
+                                continue
+
+                            marker['time'] = rel_time
+                            marker['session_timecode'] = time.strftime('%H:%M:%S', time.gmtime(rel_time))
+                            comment = self.fix_text(att['cdata'])
+                            if ":" in comment:
+                                pre, post = comment.split(':', 1)
+                                if pre.isdigit():
+                                    comment = post
+                            marker['comment'] = comment
+                            markers.append(marker)
+
+                        i += 1
+        return title, markers
+
diff --git a/teleforma/utils/xmltodict2.py b/teleforma/utils/xmltodict2.py
new file mode 100644 (file)
index 0000000..d9564d5
--- /dev/null
@@ -0,0 +1,398 @@
+# -*- coding: utf-8 -*-
+""" xmltodict(): convert xml into tree of Python dicts.
+
+This was copied and modified from John Bair's recipe at aspn.activestate.com:
+       http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/149368
+"""
+import os
+import string
+import locale
+from xml.parsers import expat
+
+# If we're in Dabo, get the default encoding.
+#import dabo
+#import dabo.lib.DesignerUtils as desUtil
+#from dabo.dLocalize import _
+#from dabo.lib.utils import resolvePath
+#app = dabo.dAppRef
+#if app is not None:
+       #default_encoding = app.Encoding
+#else:
+               #enc = locale.getlocale()[1]
+               #if enc is None:
+                       #enc = dabo.defaultEncoding
+               #default_encoding = enc
+
+# Python seems to need to compile code with \n linesep:
+code_linesep = "\n"
+eol = os.linesep
+default_encoding = 'utf-8'
+default_decoding = 'utf-8'
+
+class Xml2Obj:
+       """XML to Object"""
+       def __init__(self):
+               self.root = None
+               self.nodeStack = []
+               self.attsToSkip = []
+               self._inCode = False
+               self._mthdName = ""
+               self._mthdCode = ""
+               self._codeDict = None
+               self._inProp = False
+               self._propName = ""
+               self._propData = ""
+               self._propDict = None
+               self._currPropAtt = ""
+               self._currPropDict = None
+
+
+       def StartElement(self, name, attributes):
+               """SAX start element even handler"""
+               if name == "code":
+                       # This is code for the parent element
+                       self._inCode = True
+                       parent = self.nodeStack[-1]
+                       if "code" not in parent:
+                               parent["code"] = {}
+                               self._codeDict = parent["code"]
+
+               elif name == "properties":
+                       # These are the custom property definitions
+                       self._inProp = True
+                       self._propName = ""
+                       self._propData = ""
+                       parent = self.nodeStack[-1]
+                       if "properties" not in parent:
+                               parent["properties"] = {}
+                               self._propDict = parent["properties"]
+
+               else:
+                       if self._inCode:
+                               self._mthdName = name.encode()
+                       elif self._inProp:
+                               if self._propName:
+                                       # In the middle of a prop definition
+                                       self._currPropAtt = name.encode()
+                               else:
+                                       self._propName = name.encode()
+                                       self._currPropDict = {}
+                                       self._currPropAtt = ""
+                       else:
+                               element = {"name": name.encode()}
+                               if len(attributes) > 0:
+                                       for att in self.attsToSkip:
+                                               if att in attributes:
+                                                       del attributes[att]
+                                       element["attributes"] = attributes
+
+                               # Push element onto the stack and make it a child of parent
+                               if len(self.nodeStack) > 0:
+                                       parent = self.nodeStack[-1]
+                                       if "children" not in parent:
+                                               parent["children"] = []
+                                       parent["children"].append(element)
+                               else:
+                                       self.root = element
+                               self.nodeStack.append(element)
+
+
+       def EndElement(self, name):
+               """SAX end element event handler"""
+               if self._inCode:
+                       if name == "code":
+                               self._inCode = False
+                               self._codeDict = None
+                       else:
+                               # End of an individual method
+                               mth = self._mthdCode.strip()
+                               if not mth.endswith("\n"):
+                                       mth += "\n"
+                               self._codeDict[self._mthdName] = mth
+                               self._mthdName = ""
+                               self._mthdCode = ""
+               elif self._inProp:
+                       if name == "properties":
+                               self._inProp = False
+                               self._propDict = None
+                       elif name == self._propName:
+                               # End of an individual prop definition
+                               self._propDict[self._propName] = self._currPropDict
+                               self._propName = ""
+                       else:
+                               # end of a property attribute
+                               self._currPropDict[self._currPropAtt] = self._propData
+                               self._propData = self._currPropAtt = ""
+               else:
+                       self.nodeStack = self.nodeStack[:-1]
+
+
+       def CharacterData(self, data):
+               """SAX character data event handler"""
+               if self._inCode or data.strip():
+                       data = data.replace("&lt;", "<")
+                       data = data.encode(default_encoding)
+                       if self._inCode:
+                               if self._mthdCode:
+                                       self._mthdCode += data
+                               else:
+                                       self._mthdCode = data
+                       elif self._inProp:
+                               self._propData += data
+                       else:
+                               element = self.nodeStack[-1]
+                               if "cdata" not in element:
+                                       element["cdata"] = ""
+                               element["cdata"] += data
+
+
+       def Parse(self, xml):
+               # Create a SAX parser
+               Parser = expat.ParserCreate(default_encoding)
+               # SAX event handlers
+               Parser.StartElementHandler = self.StartElement
+               Parser.EndElementHandler = self.EndElement
+               Parser.CharacterDataHandler = self.CharacterData
+               # Parse the XML File
+               ParserStatus = Parser.Parse(xml, 1)
+               return self.root
+
+
+       def ParseFromFile(self, filename):
+               return self.Parse(open(filename,"r").read())
+
+
+def xmltodict(xml, attsToSkip=[], addCodeFile=False):
+       """Given an xml string or file, return a Python dictionary."""
+       parser = Xml2Obj()
+       parser.attsToSkip = attsToSkip
+       isPath = os.path.exists(xml)
+       errmsg = ""
+       if eol not in xml and isPath:
+               # argument was a file
+               try:
+                       ret = parser.ParseFromFile(xml)
+               except expat.ExpatError as e:
+                       errmsg = "The XML in '%s' is not well-formed and cannot be parsed: %s" % (xml, e)
+       else:
+               # argument must have been raw xml:
+               if not xml.strip().startswith("<?xml "):
+                       # it's a bad file name
+                       errmsg = "The file '%s' could not be found" % xml
+               else:
+                       try:
+                               ret = parser.Parse(xml)
+                       except expat.ExpatError:
+                               errmsg = "An invalid XML string was encountered"
+       if errmsg:
+               raise Exception(errmsg)
+
+       if addCodeFile and isPath:
+               # Get the associated code file, if any
+               codePth = "%s-code.py" % os.path.splitext(xml)[0]
+               if os.path.exists(codePth):
+                       try:
+                               codeDict = desUtil.parseCodeFile(open(codePth).read())
+                               desUtil.addCodeToClassDict(ret, codeDict)
+                       except Exception as e:
+                               print("Failed to parse code file:", e)
+       return ret
+
+
+def escQuote(val, noEscape=False, noQuote=False):
+       """Add surrounding quotes to the string, and escape
+       any illegal XML characters.
+       """
+       if not isinstance(val, str):
+               val = str(val)
+       if noQuote:
+               qt = ''
+       else:
+               qt = '"'
+       slsh = "\\"
+#      val = val.replace(slsh, slsh+slsh)
+       if not noEscape:
+               # First escape internal ampersands. We need to double them up due to a
+               # quirk in wxPython and the way it displays this character.
+               val = val.replace("&", "&amp;&amp;")
+               # Escape any internal quotes
+               val = val.replace('"', '&quot;').replace("'", "&apos;")
+               # Escape any high-order characters
+               chars = []
+               for pos, char in enumerate(list(val)):
+                       if ord(char) > 127:
+                               chars.append("&#%s;" % ord(char))
+                       else:
+                                       chars.append(char)
+               val = "".join(chars)
+       val = val.replace("<", "&#060;").replace(">", "&#062;")
+       return "%s%s%s" % (qt, val, qt)
+
+
+def dicttoxml(dct, level=0, header=None, linesep=None):
+       """Given a Python dictionary, return an xml string.
+
+       The dictionary must be in the format returned by dicttoxml(), with keys
+       on "attributes", "code", "cdata", "name", and "children".
+
+       Send your own XML header, otherwise a default one will be used.
+
+       The linesep argument is a dictionary, with keys on levels, allowing the
+       developer to add extra whitespace depending on the level.
+       """
+       att = ""
+       ret = ""
+
+       if "attributes" in dct:
+               for key, val in list(dct["attributes"].items()):
+                       # Some keys are already handled.
+                       noEscape = key in ("sizerInfo",)
+                       val = escQuote(val, noEscape)
+                       att += " %s=%s" % (key, val)
+       ret += "%s<%s%s" % ("\t" * level, dct["name"], att)
+
+       if ("cdata" not in dct and "children" not in dct
+                       and "code" not in dct and "properties" not in dct):
+               ret += " />%s" % eol
+       else:
+               ret += ">"
+               if "cdata" in dct:
+                       ret += "%s" % dct["cdata"].decode(default_encoding).replace("<", "&lt;")
+
+               if "code" in dct:
+                       if len(list(dct["code"].keys())):
+                               ret += "%s%s<code>%s" % (eol, "\t" * (level+1), eol)
+                               methodTab = "\t" * (level+2)
+                               for mthd, cd in list(dct["code"].items()):
+                                       # Convert \n's in the code to eol:
+                                       cd = eol.join(cd.splitlines())
+
+                                       # Make sure that the code ends with a linefeed
+                                       if not cd.endswith(eol):
+                                               cd += eol
+
+                                       ret += "%s<%s><![CDATA[%s%s]]>%s%s</%s>%s" % (methodTab,
+                                                       mthd, eol, cd, eol,
+                                                       methodTab, mthd, eol)
+                               ret += "%s</code>%s"    % ("\t" * (level+1), eol)
+
+               if "properties" in dct:
+                       if len(list(dct["properties"].keys())):
+                               ret += "%s%s<properties>%s" % (eol, "\t" * (level+1), eol)
+                               currTab = "\t" * (level+2)
+                               for prop, val in list(dct["properties"].items()):
+                                       ret += "%s<%s>%s" % (currTab, prop, eol)
+                                       for propItm, itmVal in list(val.items()):
+                                               itmTab = "\t" * (level+3)
+                                               ret += "%s<%s>%s</%s>%s" % (itmTab, propItm, itmVal,
+                                                               propItm, eol)
+                                       ret += "%s</%s>%s" % (currTab, prop, eol)
+                               ret += "%s</properties>%s"      % ("\t" * (level+1), eol)
+
+               if "children" in dct and len(dct["children"]) > 0:
+                       ret += eol
+                       for child in dct["children"]:
+                               ret += dicttoxml(child, level+1, linesep=linesep)
+               indnt = ""
+               if ret.endswith(eol):
+                       # Indent the closing tag
+                       indnt = ("\t" * level)
+               ret += "%s</%s>%s" % (indnt, dct["name"], eol)
+
+               if linesep:
+                       ret += linesep.get(level, "")
+
+       if level == 0:
+               if header is None:
+                       header = '<?xml version="1.0" encoding="%s" standalone="no"?>%s' \
+                                       % (default_encoding, eol)
+               ret = header + ret
+
+       return ret
+
+
+def flattenClassDict(cd, retDict=None):
+       """Given a dict containing a series of nested objects such as would
+       be created by restoring from a cdxml file, returns a dict with all classIDs
+       as keys, and a dict as the corresponding value. The dict value will have
+       keys for the attributes and/or code, depending on what was in the original
+       dict. The end result is to take a nested dict structure and return a flattened
+       dict with all objects at the top level.
+       """
+       if retDict is None:
+               retDict = {}
+       atts = cd.get("attributes", {})
+       props = cd.get("properties", {})
+       kids = cd.get("children", [])
+       code = cd.get("code", {})
+       classID = atts.get("classID", "")
+       classFile = resolvePath(atts.get("designerClass", ""))
+       superclass = resolvePath(atts.get("superclass", ""))
+       superclassID = atts.get("superclassID", "")
+       if superclassID and os.path.exists(superclass):
+               # Get the superclass info
+               superCD = xmltodict(superclass, addCodeFile=True)
+               flattenClassDict(superCD, retDict)
+       if classID:
+               if os.path.exists(classFile):
+                       # Get the class info
+                       classCD = xmltodict(classFile, addCodeFile=True)
+                       classAtts = classCD.get("attributes", {})
+                       classProps = classCD.get("properties", {})
+                       classCode = classCD.get("code", {})
+                       classKids = classCD.get("children", [])
+                       currDict = retDict.get(classID, {})
+                       retDict[classID] = {"attributes": classAtts, "code": classCode,
+                                       "properties": classProps}
+                       retDict[classID].update(currDict)
+                       # Now update the child objects in the dict
+                       for kid in classKids:
+                               flattenClassDict(kid, retDict)
+               else:
+                       # Not a file; most likely just a component in another class
+                       currDict = retDict.get(classID, {})
+                       retDict[classID] = {"attributes": atts, "code": code,
+                                       "properties": props}
+                       retDict[classID].update(currDict)
+       if kids:
+               for kid in kids:
+                       flattenClassDict(kid, retDict)
+       return retDict
+
+
+def addInheritedInfo(src, super, updateCode=False):
+       """Called recursively on the class container structure, modifying
+       the attributes to incorporate superclass information. When the
+       'updateCode' parameter is True, superclass code is added to the
+       object's code
+       """
+       atts = src.get("attributes", {})
+       props = src.get("properties", {})
+       kids = src.get("children", [])
+       code = src.get("code", {})
+       classID = atts.get("classID", "")
+       if classID:
+               superInfo = super.get(classID, {"attributes": {}, "code": {}, "properties": {}})
+               src["attributes"] = superInfo["attributes"].copy()
+               src["attributes"].update(atts)
+               src["properties"] = superInfo.get("properties", {}).copy()
+               src["properties"].update(props)
+               if updateCode:
+                       src["code"] = superInfo["code"].copy()
+                       src["code"].update(code)
+       if kids:
+               for kid in kids:
+                       addInheritedInfo(kid, super, updateCode)
+
+
+
+#if __name__ == "__main__":
+       #test_dict = {"name": "test", "attributes":{"path": "c:\\temp\\name",
+                       #"problemChars": "Welcome to <Jos\xc3\xa9's \ Stuff!>\xc2\xae".decode("latin-1")}}
+       #print "test_dict:", test_dict
+       #xml = dicttoxml(test_dict)
+       #print "xml:", xml
+       #test_dict2 = xmltodict(xml)
+       #print "test_dict2:", test_dict2
+       #print "same?:", test_dict == test_dict2
+