From: Yoan Le Clanche Date: Tue, 17 May 2022 14:00:20 +0000 (+0200) Subject: Add MediaMarker model and kdenlive script X-Git-Tag: 2.8.1-pro~115^2~9 X-Git-Url: https://git.parisson.com/?a=commitdiff_plain;h=2a86723df444604e39f14693e1c56f064d16820c;p=teleforma.git Add MediaMarker model and kdenlive script --- diff --git a/teleforma/admin.py b/teleforma/admin.py index 35563c08..4c3ed3be 100644 --- a/teleforma/admin.py +++ b/teleforma/admin.py @@ -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'] diff --git a/teleforma/migrations/0001_initial.py b/teleforma/migrations/0001_initial.py index d3062923..71dc2e6f 100644 --- a/teleforma/migrations/0001_initial.py +++ b/teleforma/migrations/0001_initial.py @@ -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', diff --git a/teleforma/models/core.py b/teleforma/models/core.py index 82e396d5..a0cf7dc4 100755 --- a/teleforma/models/core.py +++ b/teleforma/models/core.py @@ -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 index 00000000..043376e2 --- /dev/null +++ b/teleforma/utils/kdenlive.py @@ -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 + + +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 index 00000000..d9564d5a --- /dev/null +++ b/teleforma/utils/xmltodict2.py @@ -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("<", "<") + 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(" 127: + chars.append("&#%s;" % ord(char)) + else: + chars.append(char) + val = "".join(chars) + val = val.replace("<", "<").replace(">", ">") + 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("<", "<") + + if "code" in dct: + if len(list(dct["code"].keys())): + ret += "%s%s%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>%s%s%s" % (methodTab, + mthd, eol, cd, eol, + methodTab, mthd, eol) + ret += "%s%s" % ("\t" * (level+1), eol) + + if "properties" in dct: + if len(list(dct["properties"].keys())): + ret += "%s%s%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" % (itmTab, propItm, itmVal, + propItm, eol) + ret += "%s%s" % (currTab, prop, eol) + ret += "%s%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" % (indnt, dct["name"], eol) + + if linesep: + ret += linesep.get(level, "") + + if level == 0: + if header is None: + header = '%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 \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 +