From bd6002666ee2cfbd38c2810285a56f166a032f59 Mon Sep 17 00:00:00 2001 From: susanodd Date: Wed, 11 Sep 2024 12:28:16 +0200 Subject: [PATCH 01/10] #1298: Initial setup for animation model. --- requirements.txt | 2 + signbank/animation/__init__.py | 0 signbank/animation/models.py | 112 +++++++++++++++++++ signbank/settings/server_specific/default.py | 1 + 4 files changed, 115 insertions(+) create mode 100644 signbank/animation/__init__.py create mode 100644 signbank/animation/models.py diff --git a/requirements.txt b/requirements.txt index 2978e486a..0ca42c2fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,4 +32,6 @@ unicodecsv==0.14.1 uWSGI==2.0.23 requests==2.32.0 django-cors-headers==4.3.1 +sip==6.8.6 +pyfbx==0.0.8 diff --git a/signbank/animation/__init__.py b/signbank/animation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/signbank/animation/models.py b/signbank/animation/models.py new file mode 100644 index 000000000..1566b6275 --- /dev/null +++ b/signbank/animation/models.py @@ -0,0 +1,112 @@ +""" Models for the animation application +keep track of uploaded animation files and converted versions +""" + +from django.db import models +from django.conf import settings +import sys +import os +import time +import stat +import shutil + +from signbank.video.convertvideo import extract_frame, convert_video, probe_format, make_thumbnail_video + +from django.core.files.storage import FileSystemStorage +from django.contrib.auth import models as authmodels +from signbank.settings.base import WRITABLE_FOLDER, GLOSS_VIDEO_DIRECTORY, GLOSS_IMAGE_DIRECTORY, FFMPEG_PROGRAM +# from django.contrib.auth.models import User +from datetime import datetime + +from signbank.dictionary.models import * +from pyfbx import Fbx + + +if sys.argv[0] == 'mod_wsgi': + from signbank.dictionary.models import * +else: + from signbank.dictionary.models import Gloss, Language + + +def get_two_letter_dir(idgloss): + foldername = idgloss[:2] + + if len(foldername) == 1: + foldername += '-' + + return foldername + +class AnimationStorage(FileSystemStorage): + """Implement our shadowing video storage system""" + + def __init__(self, location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL): + super(AnimationStorage, self).__init__(location, base_url) + + def get_valid_name(self, name): + return name + + +storage = AnimationStorage() + +# The 'action' choices are used in the GlossVideoHistory class +ACTION_CHOICES = (('delete', 'delete'), + ('upload', 'upload'), + ('rename', 'rename'), + ('watch', 'watch'), + ('import', 'import'), + ) + + +def validate_file_extension(value): + if value.file.content_type not in ['application/octet-stream']: + raise ValidationError(u'Error message') + + +def get_animation_file_path(instance, filename): + """ + Return the full path for storing an uploaded animation + :param instance: A GlossAnimation instance + :param filename: the original file name + :return: + """ + (base, ext) = os.path.splitext(filename) + + idgloss = instance.gloss.idgloss + + animation_dir = settings.ANIMATION_DIRECTORY + try: + dataset_dir = instance.gloss.lemma.dataset.acronym + except KeyError: + dataset_dir = "" + + two_letter_dir = get_two_letter_dir(idgloss) + filename = idgloss + '-' + str(instance.gloss.id) + ext + path = os.path.join(animation_dir, dataset_dir, two_letter_dir, filename) + if hasattr(settings, 'ESCAPE_UPLOADED_VIDEO_FILE_PATH') and settings.ESCAPE_UPLOADED_VIDEO_FILE_PATH: + from django.utils.encoding import escape_uri_path + path = escape_uri_path(path) + return path + + +class GlossAnimation(models.Model): + """A video that represents a particular idgloss""" + + fbxfile = models.FileField("FBX file", storage=storage, + validators=[validate_file_extension]) + + gloss = models.ForeignKey(Gloss, on_delete=models.CASCADE) + + def __init__(self, *args, **kwargs): + if 'upload_to' in kwargs: + self.upload_to = kwargs.pop('upload_to') + else: + self.upload_to = get_animation_file_path + super().__init__(*args, **kwargs) + + def save(self, *args, **kwargs): + + super(GlossAnimation, self).save(*args, **kwargs) + + def get_absolute_url(self): + + return self.fbxfile.name diff --git a/signbank/settings/server_specific/default.py b/signbank/settings/server_specific/default.py index 2ff475599..cd6d06d52 100644 --- a/signbank/settings/server_specific/default.py +++ b/signbank/settings/server_specific/default.py @@ -9,6 +9,7 @@ GLOSS_VIDEO_DIRECTORY = 'glossvideo' EXAMPLESENTENCE_VIDEO_DIRECTORY = 'sensevideo' ANNOTATEDSENTENCE_VIDEO_DIRECTORY = 'annotatedvideo' +ANIMATION_DIRECTORY = 'animation' GLOSS_IMAGE_DIRECTORY = 'glossimage' FEEDBANK_VIDEO_DIRECTORY = 'comments' HANDSHAPE_IMAGE_DIRECTORY = 'handshapeimage' From 7dcc4cbad1628cde57707cc770e8a759f3d859f8 Mon Sep 17 00:00:00 2001 From: susanodd Date: Thu, 12 Sep 2024 13:23:03 +0200 Subject: [PATCH 02/10] #1298, #1323: Setup for animation, continued. --- signbank/animation/forms.py | 33 +++++++++++++++++++ signbank/animation/models.py | 31 +++++++++++++++++- signbank/animation/urls.py | 10 ++++++ signbank/animation/views.py | 62 +++++++++++++++++++++++++++++++++++ signbank/dictionary/models.py | 27 +++++++++++++++ 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 signbank/animation/forms.py create mode 100644 signbank/animation/urls.py create mode 100644 signbank/animation/views.py diff --git a/signbank/animation/forms.py b/signbank/animation/forms.py new file mode 100644 index 000000000..241847d71 --- /dev/null +++ b/signbank/animation/forms.py @@ -0,0 +1,33 @@ +from django import forms +from signbank.animation.models import GlossAnimation +from signbank.dictionary.models import Gloss +import json +from django.utils.translation import gettext_lazy as _ + + +class AnimationUploadForm(forms.ModelForm): + """Form for animation upload""" + + class Meta: + model = GlossAnimation + fields = '__all__' + + +ATTRS_FOR_FORMS = {'class': 'form-control'} + + +class AnimationUploadForObjectForm(forms.Form): + """Form for animation upload""" + + fbxfile = forms.FileField(label=_("Upload FBX File"), + widget=forms.FileInput(attrs={'accept': 'application/octet-stream'})) + object_id = forms.CharField(widget=forms.HiddenInput) + object_type = forms.CharField(widget=forms.HiddenInput) + redirect = forms.CharField(widget=forms.HiddenInput, required=False) + recorded = forms.BooleanField(initial=False, required=False) + offset = forms.IntegerField(required=False) + + def __init__(self, *args, **kwargs): + languages = kwargs.pop('languages', []) + dataset = kwargs.pop('dataset', None) + super(AnimationUploadForObjectForm, self).__init__(*args, **kwargs) diff --git a/signbank/animation/models.py b/signbank/animation/models.py index 1566b6275..68e204e1d 100644 --- a/signbank/animation/models.py +++ b/signbank/animation/models.py @@ -37,7 +37,7 @@ def get_two_letter_dir(idgloss): return foldername class AnimationStorage(FileSystemStorage): - """Implement our shadowing video storage system""" + """Implement our shadowing animation storage system""" def __init__(self, location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL): super(AnimationStorage, self).__init__(location, base_url) @@ -110,3 +110,32 @@ def save(self, *args, **kwargs): def get_absolute_url(self): return self.fbxfile.name + + +class GlossAnimationHistory(models.Model): + """History of video uploading and deletion""" + + action = models.CharField("Animation History Action", max_length=6, choices=ACTION_CHOICES, default='watch') + # When was this action done? + datestamp = models.DateTimeField("Date and time of action", auto_now_add=True) + # See 'fbxfile' in animation.views.addanimation + uploadfile = models.TextField("User upload path", default='(not specified)') + # See 'goal_location' in addanimation + goal_location = models.TextField("Full target path", default='(not specified)') + + # WAS: Many-to-many link: to the user that has uploaded or deleted this video + # WAS: actor = models.ManyToManyField("", User) + # The user that has uploaded or deleted this video + actor = models.ForeignKey(authmodels.User, on_delete=models.CASCADE) + + # One-to-many link: to the Gloss in dictionary.models.Gloss + gloss = models.ForeignKey(Gloss, on_delete=models.CASCADE) + + def __str__(self): + + # Basic feedback from one History item: gloss-action-date + name = self.gloss.idgloss + ': ' + self.action + ', (' + str(self.datestamp) + ')' + return str(name.encode('ascii', errors='replace')) + + class Meta: + ordering = ['datestamp'] diff --git a/signbank/animation/urls.py b/signbank/animation/urls.py new file mode 100644 index 000000000..c283bcd17 --- /dev/null +++ b/signbank/animation/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import * +from django.contrib.auth.decorators import permission_required +from django.urls import re_path, path, include + +import signbank.animation.views + +urlpatterns = [ + re_path(r'^animation/(?P\d+)$', signbank.animation.views.animation), + re_path(r'^upload/', signbank.video.views.addvideo) + ] diff --git a/signbank/animation/views.py b/signbank/animation/views.py new file mode 100644 index 000000000..c89a03e6c --- /dev/null +++ b/signbank/animation/views.py @@ -0,0 +1,62 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.template import Context, RequestContext +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.http import HttpResponse +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext as _ + +from signbank.animation.models import GlossAnimation +from signbank.dictionary.models import Gloss, DeletedGlossOrMedia, ExampleSentence, Morpheme, AnnotatedSentence, Dataset, AnnotatedSentenceSource +from signbank.animation.forms import AnimationUploadForObjectForm +from django.http import JsonResponse +# from django.contrib.auth.models import User +# from datetime import datetime as DT +import os +import json + +from signbank.settings.base import WRITABLE_FOLDER +from signbank.tools import generate_still_image, get_default_annotationidglosstranslation + + +def addanimation(request): + """View to present an animation upload form and process the upload""" + + if request.method == 'POST': + last_used_dataset = request.session['last_used_dataset'] + dataset = Dataset.objects.filter(acronym=last_used_dataset).first() + dataset_languages = dataset.translation_languages.all() + form = AnimationUploadForObjectForm(request.POST, request.FILES, languages=dataset_languages, dataset=dataset) + if form.is_valid(): + # Unpack the form + object_id = form.cleaned_data['object_id'] + object_type = form.cleaned_data['object_type'] + fbxfile = form.cleaned_data['fbxfile'] + redirect_url = form.cleaned_data['redirect'] + if object_type == 'gloss_animation': + gloss = Gloss.objects.filter(id=object_id).first() + if not gloss: + redirect(redirect_url) + gloss.add_animation(request.user, fbxfile) + + return redirect(redirect_url) + + # if we can't process the form, just redirect back to the + # referring page, should just be the case of hitting + # Upload without choosing a file but could be + # a malicious request, if no referrer, go back to root + if 'HTTP_REFERER' in request.META: + url = request.META['HTTP_REFERER'] + else: + url = '/' + return redirect(url) + + +def animation(request, animationid): + """Redirect to the animation url for this animationid""" + + animation = get_object_or_404(GlossAnimation, id=animationid, gloss__archived=False) + + return redirect(animation) + diff --git a/signbank/dictionary/models.py b/signbank/dictionary/models.py index 5590ee2c9..b04a10976 100755 --- a/signbank/dictionary/models.py +++ b/signbank/dictionary/models.py @@ -2630,6 +2630,33 @@ def tags(self): from tagging.models import Tag return Tag.objects.get_for_object(self) + def add_animation(self, user, fbxfile): + # Preventing circular import + from signbank.animation.models import GlossAnimation, get_animation_file_path, GlossAnimationHistory + + # Create a new GlossAnimation object + if isinstance(fbxfile, File) or fbxfile.content_type == 'django.core.files.uploadedfile.InMemoryUploadedFile': + animation = GlossAnimation(gloss=self, upload_to=get_animation_file_path) + # Backup the existing animation objects stored in the database + existing_animations = GlossAnimation.objects.filter(gloss=self) + for animation_object in existing_animations: + animation_object.reversion(revert=False) + + # Create a GlossAnimationHistory object + relative_path = get_animation_file_path(animation, str(fbxfile)) + animation_file_full_path = os.path.join(WRITABLE_FOLDER, relative_path) + glossanimationhistory = GlossAnimationHistory(action="upload", gloss=self, actor=user, + uploadfile=fbxfile, goal_location=animation_file_full_path) + glossanimationhistory.save() + + # Save the new fbx file in the animation object + animation.fbxfile.save(relative_path, fbxfile) + else: + return GlossAnimation(gloss=self, upload_to=get_animation_file_path) + animation.save() + + return animation + # register Gloss for tags try: tagging.register(Gloss) From e60e583f1269b1aa058d1d2a6b5aab258df23a28 Mon Sep 17 00:00:00 2001 From: susanodd Date: Thu, 12 Sep 2024 13:35:57 +0200 Subject: [PATCH 03/10] #1298, #1323: Setup for animation, added admin. --- signbank/animation/admin.py | 61 +++++++++++++++++++++++++++++++++++++ signbank/animation/urls.py | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 signbank/animation/admin.py diff --git a/signbank/animation/admin.py b/signbank/animation/admin.py new file mode 100644 index 000000000..643093814 --- /dev/null +++ b/signbank/animation/admin.py @@ -0,0 +1,61 @@ +from django.contrib import admin +from django import forms +from django.db import models +from signbank.animation.models import GlossAnimation, GlossAnimationHistory +from signbank.dictionary.models import Dataset, AnnotatedGloss +from django.contrib.auth.models import User +from signbank.settings.base import * +from signbank.settings.server_specific import WRITABLE_FOLDER, FILESYSTEM_SIGNBANK_GROUPS +from django.utils.translation import override, gettext_lazy as _ +from django.db.models import Q, Count, CharField, TextField, Value as V + + +class GlossAnimationAdmin(admin.ModelAdmin): + + list_display = ['id', 'gloss', 'fbx_file', 'file_timestamp', 'file_size'] + + search_fields = ['^gloss__annotationidglosstranslation__text'] + + def fbx_file(self, obj=None): + # this will display the full path in the list view + if obj is None: + return "" + import os + file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.fbxfile)) + + return file_full_path + + def file_timestamp(self, obj=None): + # if the file exists, this will display its timestamp in the list view + if obj is None: + return "" + import os + import datetime + file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.fbxfile)) + if os.path.exists(file_full_path): + return datetime.datetime.fromtimestamp(os.path.getctime(file_full_path)) + else: + return "" + + def file_size(self, obj=None): + # this will display a group in the list view + if obj is None: + return "" + else: + from pathlib import Path + file_full_path = Path(WRITABLE_FOLDER, str(obj.fbxfile)) + if file_full_path.exists(): + size = str(file_full_path.stat().st_size) + return size + else: + return "" + + +class GlossAnnotationHistoryAdmin(admin.ModelAdmin): + + list_display = ['action', 'datestamp', 'uploadfile', 'goal_location', 'actor', 'gloss'] + + search_fields = ['^gloss__annotationidglosstranslation__text'] + +admin.site.register(GlossAnimation, GlossAnimationAdmin) +admin.site.register(GlossAnimationHistory, GlossAnnotationHistoryAdmin) diff --git a/signbank/animation/urls.py b/signbank/animation/urls.py index c283bcd17..e0946ead3 100644 --- a/signbank/animation/urls.py +++ b/signbank/animation/urls.py @@ -6,5 +6,5 @@ urlpatterns = [ re_path(r'^animation/(?P\d+)$', signbank.animation.views.animation), - re_path(r'^upload/', signbank.video.views.addvideo) + re_path(r'^upload/', signbank.animation.views.addanimation) ] From 08b556711c95143b262ec4985499968ad97d8b5e Mon Sep 17 00:00:00 2001 From: susanodd Date: Thu, 12 Sep 2024 17:35:12 +0200 Subject: [PATCH 04/10] #1298, #1323: Create a gloss animation object. Admin for gloss animation, migration for models. --- signbank/animation/forms.py | 6 +- signbank/animation/migrations/0001_initial.py | 42 +++++++ signbank/animation/migrations/__init__.py | 0 signbank/animation/models.py | 22 ++-- signbank/animation/views.py | 4 +- signbank/dictionary/adminviews.py | 77 +++++++++++++ signbank/dictionary/models.py | 7 +- .../templates/dictionary/add_animation.html | 107 ++++++++++++++++++ signbank/dictionary/urls.py | 4 +- signbank/settings/base.py | 3 +- signbank/urls.py | 4 +- 11 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 signbank/animation/migrations/0001_initial.py create mode 100644 signbank/animation/migrations/__init__.py create mode 100644 signbank/dictionary/templates/dictionary/add_animation.html diff --git a/signbank/animation/forms.py b/signbank/animation/forms.py index 241847d71..00dcfaf44 100644 --- a/signbank/animation/forms.py +++ b/signbank/animation/forms.py @@ -21,11 +21,9 @@ class AnimationUploadForObjectForm(forms.Form): fbxfile = forms.FileField(label=_("Upload FBX File"), widget=forms.FileInput(attrs={'accept': 'application/octet-stream'})) - object_id = forms.CharField(widget=forms.HiddenInput) + gloss_id = forms.CharField(widget=forms.HiddenInput) object_type = forms.CharField(widget=forms.HiddenInput) - redirect = forms.CharField(widget=forms.HiddenInput, required=False) - recorded = forms.BooleanField(initial=False, required=False) - offset = forms.IntegerField(required=False) + redirect = forms.CharField(widget=forms.HiddenInput) def __init__(self, *args, **kwargs): languages = kwargs.pop('languages', []) diff --git a/signbank/animation/migrations/0001_initial.py b/signbank/animation/migrations/0001_initial.py new file mode 100644 index 000000000..b80014511 --- /dev/null +++ b/signbank/animation/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.15 on 2024-09-12 14:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import signbank.animation.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dictionary', '0086_alter_dataset_options'), + ] + + operations = [ + migrations.CreateModel( + name='GlossAnimationHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('delete', 'delete'), ('upload', 'upload'), ('rename', 'rename'), ('watch', 'watch'), ('import', 'import')], default='watch', max_length=6, verbose_name='Animation History Action')), + ('datestamp', models.DateTimeField(auto_now_add=True, verbose_name='Date and time of action')), + ('uploadfile', models.TextField(default='(not specified)', verbose_name='User upload path')), + ('goal_location', models.TextField(default='(not specified)', verbose_name='Full target path')), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('gloss', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dictionary.gloss')), + ], + options={ + 'ordering': ['datestamp'], + }, + ), + migrations.CreateModel( + name='GlossAnimation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fbxfile', models.FileField(storage=signbank.animation.models.AnimationStorage(), upload_to='', validators=[signbank.animation.models.validate_file_extension], verbose_name='FBX file')), + ('gloss', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dictionary.gloss')), + ], + ), + ] diff --git a/signbank/animation/migrations/__init__.py b/signbank/animation/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/signbank/animation/models.py b/signbank/animation/models.py index 68e204e1d..b2fba94b9 100644 --- a/signbank/animation/models.py +++ b/signbank/animation/models.py @@ -10,11 +10,9 @@ import stat import shutil -from signbank.video.convertvideo import extract_frame, convert_video, probe_format, make_thumbnail_video - from django.core.files.storage import FileSystemStorage from django.contrib.auth import models as authmodels -from signbank.settings.base import WRITABLE_FOLDER, GLOSS_VIDEO_DIRECTORY, GLOSS_IMAGE_DIRECTORY, FFMPEG_PROGRAM +from signbank.settings.server_specific import WRITABLE_FOLDER, ANIMATION_DIRECTORY # from django.contrib.auth.models import User from datetime import datetime @@ -48,7 +46,7 @@ def get_valid_name(self, name): storage = AnimationStorage() -# The 'action' choices are used in the GlossVideoHistory class +# The 'action' choices are used in the GlossAnimationHistory class ACTION_CHOICES = (('delete', 'delete'), ('upload', 'upload'), ('rename', 'rename'), @@ -89,7 +87,7 @@ def get_animation_file_path(instance, filename): class GlossAnimation(models.Model): - """A video that represents a particular idgloss""" + """An animation that represents a particular idgloss""" fbxfile = models.FileField("FBX file", storage=storage, validators=[validate_file_extension]) @@ -113,7 +111,7 @@ def get_absolute_url(self): class GlossAnimationHistory(models.Model): - """History of video uploading and deletion""" + """History of animation uploading and deletion""" action = models.CharField("Animation History Action", max_length=6, choices=ACTION_CHOICES, default='watch') # When was this action done? @@ -123,19 +121,23 @@ class GlossAnimationHistory(models.Model): # See 'goal_location' in addanimation goal_location = models.TextField("Full target path", default='(not specified)') - # WAS: Many-to-many link: to the user that has uploaded or deleted this video + # WAS: Many-to-many link: to the user that has uploaded or deleted this animation # WAS: actor = models.ManyToManyField("", User) - # The user that has uploaded or deleted this video + # The user that has uploaded or deleted this animation actor = models.ForeignKey(authmodels.User, on_delete=models.CASCADE) # One-to-many link: to the Gloss in dictionary.models.Gloss gloss = models.ForeignKey(Gloss, on_delete=models.CASCADE) + class Meta: + ordering = ['datestamp'] + def __str__(self): # Basic feedback from one History item: gloss-action-date name = self.gloss.idgloss + ': ' + self.action + ', (' + str(self.datestamp) + ')' return str(name.encode('ascii', errors='replace')) - class Meta: - ordering = ['datestamp'] + def save(self, *args, **kwargs): + + super(GlossAnimationHistory, self).save(*args, **kwargs) diff --git a/signbank/animation/views.py b/signbank/animation/views.py index c89a03e6c..c3bc819b5 100644 --- a/signbank/animation/views.py +++ b/signbank/animation/views.py @@ -30,12 +30,12 @@ def addanimation(request): form = AnimationUploadForObjectForm(request.POST, request.FILES, languages=dataset_languages, dataset=dataset) if form.is_valid(): # Unpack the form - object_id = form.cleaned_data['object_id'] + gloss_id = form.cleaned_data['gloss_id'] object_type = form.cleaned_data['object_type'] fbxfile = form.cleaned_data['fbxfile'] redirect_url = form.cleaned_data['redirect'] if object_type == 'gloss_animation': - gloss = Gloss.objects.filter(id=object_id).first() + gloss = Gloss.objects.filter(id=int(gloss_id)).first() if not gloss: redirect(redirect_url) gloss.add_animation(request.user, fbxfile) diff --git a/signbank/dictionary/adminviews.py b/signbank/dictionary/adminviews.py index 3509cf1bf..3767d3071 100755 --- a/signbank/dictionary/adminviews.py +++ b/signbank/dictionary/adminviews.py @@ -29,6 +29,8 @@ from signbank.feedback.models import * from signbank.video.forms import VideoUploadForObjectForm from signbank.video.models import GlossVideoDescription, GlossVideo, GlossVideoNME +from signbank.animation.models import GlossAnimation +from signbank.animation.forms import AnimationUploadForObjectForm from tagging.models import Tag, TaggedItem from signbank.settings.server_specific import * @@ -7573,3 +7575,78 @@ def annotatedglosslist_ajax_complete(request, annotatedgloss_id): 'column_values': column_values, 'USE_REGULAR_EXPRESSIONS': USE_REGULAR_EXPRESSIONS, 'SHOW_DATASET_INTERFACE_OPTIONS': SHOW_DATASET_INTERFACE_OPTIONS}) + + +class AnimationCreateView(CreateView): + model = GlossAnimation + template_name = 'dictionary/add_animation.html' + last_used_dataset = None + fields = [] + + def get_context_data(self, **kwargs): + context = super(AnimationCreateView, self).get_context_data(**kwargs) + + context['SHOW_DATASET_INTERFACE_OPTIONS'] = getattr(settings, 'SHOW_DATASET_INTERFACE_OPTIONS', False) + context['USE_REGULAR_EXPRESSIONS'] = getattr(settings, 'USE_REGULAR_EXPRESSIONS', False) + + selected_datasets = get_selected_datasets_for_user(self.request.user) + context['selected_datasets'] = selected_datasets + dataset_languages = get_dataset_languages(selected_datasets) + context['dataset_languages'] = dataset_languages + context['dataset'] = selected_datasets.first() + + if len(selected_datasets) == 1: + self.last_used_dataset = selected_datasets[0].acronym + elif 'last_used_dataset' in self.request.session.keys(): + self.last_used_dataset = self.request.session['last_used_dataset'] + + context['last_used_dataset'] = self.last_used_dataset + + context['default_dataset_lang'] = dataset_languages.first().language_code_2char if dataset_languages else LANGUAGE_CODE + context['add_animation_form'] = AnimationUploadForObjectForm(self.request.GET, languages=dataset_languages, dataset=self.last_used_dataset) + + return context + + def post(self, request, *args, **kwargs): + dataset = None + if 'dataset' in request.POST and request.POST['dataset'] is not None: + dataset = Dataset.objects.get(pk=request.POST['dataset']) + selected_datasets = Dataset.objects.filter(pk=request.POST['dataset']) + else: + selected_datasets = get_selected_datasets_for_user(request.user) + dataset_languages = get_dataset_languages(selected_datasets) + + dataset = selected_datasets.first() + + show_dataset_interface = getattr(settings, 'SHOW_DATASET_INTERFACE_OPTIONS', False) + use_regular_expressions = getattr(settings, 'USE_REGULAR_EXPRESSIONS', False) + + form = AnimationUploadForObjectForm(request.POST, languages=dataset_languages, dataset=self.last_used_dataset) + + for item, value in request.POST.items(): + print(item, value) + + if form.is_valid(): + try: + animation = form.save() + print("ANIMATION " + str(animation.pk)) + except ValidationError as ve: + messages.add_message(request, messages.ERROR, ve.message) + return render(request, 'dictionary/add_animation.html', + {'add_animation_form': AnimationUploadForObjectForm(request.POST, + languages=dataset_languages, + dataset=self.last_used_dataset), + 'dataset_languages': dataset_languages, + 'dataset': dataset, + 'selected_datasets': get_selected_datasets_for_user(request.user), + 'USE_REGULAR_EXPRESSIONS': use_regular_expressions, + 'SHOW_DATASET_INTERFACE_OPTIONS': show_dataset_interface}) + + return HttpResponseRedirect(reverse('dictionary:admin_gloss_list')) + else: + return render(request, 'dictionary/add_animation.html', {'add_animation_form': form, + 'dataset_languages': dataset_languages, + 'dataset': dataset, + 'selected_datasets': get_selected_datasets_for_user(request.user), + 'USE_REGULAR_EXPRESSIONS': use_regular_expressions, + 'SHOW_DATASET_INTERFACE_OPTIONS': show_dataset_interface}) diff --git a/signbank/dictionary/models.py b/signbank/dictionary/models.py index b04a10976..2692af7d2 100755 --- a/signbank/dictionary/models.py +++ b/signbank/dictionary/models.py @@ -2637,20 +2637,17 @@ def add_animation(self, user, fbxfile): # Create a new GlossAnimation object if isinstance(fbxfile, File) or fbxfile.content_type == 'django.core.files.uploadedfile.InMemoryUploadedFile': animation = GlossAnimation(gloss=self, upload_to=get_animation_file_path) - # Backup the existing animation objects stored in the database - existing_animations = GlossAnimation.objects.filter(gloss=self) - for animation_object in existing_animations: - animation_object.reversion(revert=False) # Create a GlossAnimationHistory object relative_path = get_animation_file_path(animation, str(fbxfile)) animation_file_full_path = os.path.join(WRITABLE_FOLDER, relative_path) glossanimationhistory = GlossAnimationHistory(action="upload", gloss=self, actor=user, uploadfile=fbxfile, goal_location=animation_file_full_path) - glossanimationhistory.save() # Save the new fbx file in the animation object animation.fbxfile.save(relative_path, fbxfile) + glossanimationhistory.save() + else: return GlossAnimation(gloss=self, upload_to=get_animation_file_path) animation.save() diff --git a/signbank/dictionary/templates/dictionary/add_animation.html b/signbank/dictionary/templates/dictionary/add_animation.html new file mode 100644 index 000000000..27944e85f --- /dev/null +++ b/signbank/dictionary/templates/dictionary/add_animation.html @@ -0,0 +1,107 @@ +{% extends 'baselayout.html' %} +{% load i18n %} +{% load stylesheet %} +{% load bootstrap3 %} +{% load guardian_tags %} +{% block bootstrap3_title %} +{% blocktrans %}Signbank: Create New Animation{% endblocktrans %} +{% endblock %} + +{% block extrajs %} + + + + +{% endblock %} + +{% block content %} + +{% get_obj_perms request.user for dataset as "dataset_perms" %} + + {% if "change_dataset" in dataset_perms %} + +
+
+ {% csrf_token %} +
+

{% trans "Upload New Animation" %}

+ + + + + + + + + + +
+ + +
{{add_animation_form.fbxfile}}
+ + + +
+
+
+
+ {% else %} +

(% trans "You are not allowed to add animations." %}

+ {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/signbank/dictionary/urls.py b/signbank/dictionary/urls.py index 2714cd4c7..1bada1dd9 100755 --- a/signbank/dictionary/urls.py +++ b/signbank/dictionary/urls.py @@ -8,7 +8,8 @@ from signbank.dictionary.adminviews import (GlossListView, GlossDetailView, GlossFrequencyView, GlossRelationsDetailView, MorphemeDetailView, MorphemeListView, HandshapeDetailView, HandshapeListView, LemmaListView, LemmaCreateView, LemmaDeleteView, LemmaFrequencyView, create_lemma_for_gloss, LemmaUpdateView, SemanticFieldDetailView, SemanticFieldListView, DerivationHistoryDetailView, - DerivationHistoryListView, GlossVideosView, KeywordListView, AnnotatedSentenceDetailView, AnnotatedSentenceListView) + DerivationHistoryListView, GlossVideosView, KeywordListView, AnnotatedSentenceDetailView, AnnotatedSentenceListView, + AnimationCreateView) from signbank.dictionary.views import create_citation_image @@ -260,6 +261,7 @@ re_path(r'lemma/add/(?P\d+)$', signbank.dictionary.adminviews.create_lemma_for_gloss, name='create_lemma_gloss'), re_path(r'lemma/update/(?P\d+)$', permission_required('dictionary.change_lemmaidgloss')(LemmaUpdateView.as_view()), name='change_lemma'), re_path(r'^annotatedsentence/(?P\d+)', AnnotatedSentenceDetailView.as_view(), name='admin_annotated_sentence_view'), + re_path(r'animation/add/$', permission_required('dictionary.change_glosss')(AnimationCreateView.as_view()), name='create_animation'), re_path(r'^keywords/$', KeywordListView.as_view(), name='admin_keyword_list'), diff --git a/signbank/settings/base.py b/signbank/settings/base.py index 04b907fee..5893cfbe8 100755 --- a/signbank/settings/base.py +++ b/signbank/settings/base.py @@ -135,7 +135,8 @@ #'signbank.registration', 'signbank.pages', 'signbank.attachments', - 'signbank.video' + 'signbank.video', + 'signbank.animation' # 'debug_toolbar', # 'video_encoding' ) diff --git a/signbank/urls.py b/signbank/urls.py index acdede274..80821a9b9 100755 --- a/signbank/urls.py +++ b/signbank/urls.py @@ -15,6 +15,8 @@ import signbank.attachments.urls import signbank.video.urls import signbank.video.views +import signbank.animation.urls + import signbank.registration.urls import django.contrib.auth.views import django.contrib.admindocs.urls @@ -51,7 +53,7 @@ re_path(r'^feedback/', include(signbank.feedback.urls)), re_path(r'^attachments/', include(signbank.attachments.urls)), re_path(r'^video/', include(signbank.video.urls)), - + re_path(r'^animation/', include(signbank.animation.urls)), re_path(r'^image/upload/', add_image), re_path(r'^handshapeimage/upload/', add_handshape_image), re_path(r'^image/delete/(?P[0-9]+)$', delete_image), From 444dccf765cf682fa050c91daac772b59383b53f Mon Sep 17 00:00:00 2001 From: susanodd Date: Mon, 16 Sep 2024 15:18:19 +0200 Subject: [PATCH 05/10] #1298, #1323: Folder and view for babylon code. --- media/babylon/SceneAndMeshLoader.js | 311 +++++++++++ media/babylon/ScreenRecorder.js | 63 +++ media/babylon/animFetchAndDestroy.js | 165 ++++++ media/babylon/cameraController.js | 227 ++++++++ media/babylon/eyeBlink.js | 76 +++ media/babylon/initialize.js | 241 +++++++++ media/babylon/messageUEServer.js | 187 +++++++ media/babylon/noiseGenerator.js | 28 + media/babylon/onboard.js | 75 +++ media/babylon/playAnims.js | 511 ++++++++++++++++++ media/babylon/playerControls.js | 402 ++++++++++++++ media/babylon/retargetAnims.js | 260 +++++++++ media/babylon/signBuilder.js | 102 ++++ media/babylon/signCollectLoader.js | 43 ++ media/images/finger.svg | 2 + media/images/hand.svg | 4 + media/images/pause.svg | 10 + media/images/play.svg | 4 + media/images/speed.svg | 4 + signbank/dictionary/adminviews.py | 31 ++ .../templates/dictionary/gloss_animation.html | 463 ++++++++++++++++ signbank/dictionary/urls.py | 3 +- 22 files changed, 3211 insertions(+), 1 deletion(-) create mode 100644 media/babylon/SceneAndMeshLoader.js create mode 100644 media/babylon/ScreenRecorder.js create mode 100644 media/babylon/animFetchAndDestroy.js create mode 100644 media/babylon/cameraController.js create mode 100644 media/babylon/eyeBlink.js create mode 100644 media/babylon/initialize.js create mode 100644 media/babylon/messageUEServer.js create mode 100644 media/babylon/noiseGenerator.js create mode 100644 media/babylon/onboard.js create mode 100644 media/babylon/playAnims.js create mode 100644 media/babylon/playerControls.js create mode 100644 media/babylon/retargetAnims.js create mode 100644 media/babylon/signBuilder.js create mode 100644 media/babylon/signCollectLoader.js create mode 100644 media/images/finger.svg create mode 100644 media/images/hand.svg create mode 100644 media/images/pause.svg create mode 100644 media/images/play.svg create mode 100644 media/images/speed.svg create mode 100644 signbank/dictionary/templates/dictionary/gloss_animation.html diff --git a/media/babylon/SceneAndMeshLoader.js b/media/babylon/SceneAndMeshLoader.js new file mode 100644 index 000000000..719b51ed0 --- /dev/null +++ b/media/babylon/SceneAndMeshLoader.js @@ -0,0 +1,311 @@ +async function createScene(canvas) { + console.log("Loading Scene!"); + + var options = { + antialias: true, // Enable or disable antialiasing + powerPreference: "high-performance", + stencil: true, + }; + + var engine = new BABYLON.Engine(canvas, options); + engine.disableManifestCheck = true //disable manifest checking for + + BABYLON.Animation.AllowMatricesInterpolation = true; + + // This creates a basic Babylon Scene object (non-mesh) + var scene = new BABYLON.Scene(engine); + + // This creates a light, aiming 0,1,0 - to the sky (non-mesh) + var light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene); + // light.diffuse = new BABYLON.Color3(1, 0.98, 0.82); + // light.specular = new BABYLON.Color3(0.23, 0.23, 0.23); + // light.groundColor = new BABYLON.Color3(0, 0, 0); + + console.log("Scene and mesh loaded successfully."); + return [scene, engine]; +}; + +var loadAssetMesh = async function (scene, path = basePathMesh + "Nemu/", fileName = "Nemu.glb", bugger = false) { + console.log("Loading mesh from: " + path + fileName + "..."); + + // TODO: When clicking the button twice, the animation first frame loads + BABYLON.SceneLoader.OnPluginActivatedObservable.add(function (loader) { + if (loader.name == "gltf" || loader.name == "glb") { + loader.animationStartMode = BABYLON.GLTFLoaderAnimationStartMode.NONE; + } + }); + + if (bugger) { + scene.debugLayer.show({ + embedMode: true + }); + } + + const asset = { + fetched: await BABYLON.SceneLoader.ImportMeshAsync(null, path, fileName, scene), + root: null, + faceMesh: null, + teethMesh: null, + hips: null, + eyeMesh: null, + morphTargetManagers: [], + skeletons: [], + animationGroups: [], + papa: null, + opa: null, + god: null, + resetMorphs: function resetMorphTargets() { + // Loop through all the meshes in the scene + this.fetched.meshes.forEach(mesh => { + // Check if the mesh has a MorphTargetManager + if (mesh.morphTargetManager) { + // Get the MorphTargetManager + let morphTargetManager = mesh.morphTargetManager; + + // Loop through each morph target in the MorphTargetManager + for (let i = 0; i < morphTargetManager.numTargets; i++) { + let morphTarget = morphTargetManager.getTarget(i); + + // Set the influence (value) of the morph target to 0 + morphTarget.influence = 0; + } + } + }); + }, + }; + + // Find all animation groups + for (animGroup of scene.animationGroups) { + asset.animationGroups.push(animGroup); + } + + // Find the root mesh and the face mesh for its morph target manager + for (mesh of asset.fetched.meshes) { + mesh.position = new BABYLON.Vector3(0, 0, 0); + + if (mesh.name === "__root__") { + asset.root = mesh; + } else if (mesh.name === "newNeutral_primitive0") { + asset.eyeMesh = mesh; + } else if (mesh.name === "newNeutral_primitive1") { + asset.faceMesh = mesh; + } else if (mesh.name === "newNeutral_primitive2") { + asset.teethMesh = mesh; + } + + if (mesh.morphTargetManager) { + asset.morphTargetManagers.push(mesh.morphTargetManager); + } + } + + // Put the root mesh node in an empty transform node + var rootTransformNode = new BABYLON.TransformNode("papa"); + asset.root.parent = rootTransformNode; + asset.papa = rootTransformNode; + var papaTransformNode = new BABYLON.TransformNode("opa"); + asset.papa.parent = papaTransformNode; + asset.opa = papaTransformNode; + var opaTransformNode = new BABYLON.TransformNode("god"); + asset.opa.parent = opaTransformNode; + asset.god = opaTransformNode; + + // Find all skeletons + for (skeleton of asset.fetched.skeletons) { + asset.skeletons.push(skeleton); + } + + // Find the hips transform node + for (transformNode of asset.fetched.transformNodes) { + if (transformNode.name === "Hips" || transformNode.name === "hips" || transformNode.name === "pelvis" || transformNode.name === "Pelvis") { + asset.hips = transformNode; + } + } + + return asset; +}; + +var rotateMesh180 = function (mesh) { + mesh.rotation = new BABYLON.Vector3(BABYLON.Tools.ToRadians(0), BABYLON.Tools.ToRadians(180), BABYLON.Tools.ToRadians(0)); + + // WE SHOULD ROT LIKE THIS: + // loadedMesh.fetched.meshes.forEach(mesh => { + // mesh.rotate(BABYLON.Axis.X, Math.PI/4, BABYLON.Space.WORLD); + // console.log("rotted"); + // }); +}; + + +var setLightOnMesh = function (scene, mesh) { + var topLight = new BABYLON.PointLight("topLight", mesh.getAbsolutePosition().add(new BABYLON.Vector3(0, 4, 0)), scene); + topLight.diffuse = new BABYLON.Color3(1, 1, 1); // Set light color + topLight.intensity = 1; // Set light intensity +} + +// Local Axes function, made for debugging purposes. We can view the local axes of a mesh. +function localAxes(size, mesh, scene) { + var pilot_local_axisX = BABYLON.Mesh.CreateLines("pilot_local_axisX", [ + new BABYLON.Vector3.Zero(), new BABYLON.Vector3(size, 0, 0), new BABYLON.Vector3(size * 0.95, 0.05 * size, 0), + new BABYLON.Vector3(size, 0, 0), new BABYLON.Vector3(size * 0.95, -0.05 * size, 0) + ], scene); + pilot_local_axisX.color = new BABYLON.Color3(1, 0, 0); + + pilot_local_axisY = BABYLON.Mesh.CreateLines("pilot_local_axisY", [ + new BABYLON.Vector3.Zero(), new BABYLON.Vector3(0, size, 0), new BABYLON.Vector3(-0.05 * size, size * 0.95, 0), + new BABYLON.Vector3(0, size, 0), new BABYLON.Vector3(0.05 * size, size * 0.95, 0) + ], scene); + pilot_local_axisY.color = new BABYLON.Color3(0, 1, 0); + + var pilot_local_axisZ = BABYLON.Mesh.CreateLines("pilot_local_axisZ", [ + new BABYLON.Vector3.Zero(), new BABYLON.Vector3(0, 0, size), new BABYLON.Vector3(0, -0.05 * size, size * 0.95), + new BABYLON.Vector3(0, 0, size), new BABYLON.Vector3(0, 0.05 * size, size * 0.95) + ], scene); + pilot_local_axisZ.color = new BABYLON.Color3(0, 0, 1); + + var local_origin = BABYLON.MeshBuilder.CreateBox("local_origin", { size: 1 }, scene); + local_origin.isVisible = false; + + pilot_local_axisX.parent = mesh; + pilot_local_axisY.parent = mesh; + pilot_local_axisZ.parent = mesh; +} + +function hipsFrontAxes(size, mesh, scene) { + var localHipsAxis = BABYLON.Mesh.CreateLines("localHipsAxis", [ + new BABYLON.Vector3.Zero(), new BABYLON.Vector3(0, -size, 0), new BABYLON.Vector3(-0.05 * size, -size * 0.95, 0), + new BABYLON.Vector3(0, -size, 0), new BABYLON.Vector3(0.05 * size, -size * 0.95, 0) + ], scene); + localHipsAxis.color = new BABYLON.Color3(0, 1, 1); + + localHipsAxis.parent = mesh; +} + +function generateKey(frame, value) { + return { + frame: frame, + value: value + }; +}; +var pineappleResult = ""; +var createPineapple = async function (scene, basePathMesh, targetMesh) { + console.log("Creating pineapple..."); + //🍍 + pineappleResult = await BABYLON.SceneLoader.ImportMeshAsync(null, basePathMesh, "pineapple.glb", scene); + + if (pineappleResult.meshes.length > 0) { + const pineappleMesh = pineappleResult.meshes[0]; // Get the first mesh from the imported meshes + pineappleMesh.rotation = new BABYLON.Vector3(BABYLON.Tools.ToRadians(0), BABYLON.Tools.ToRadians(0), BABYLON.Tools.ToRadians(0)); + pineappleMesh.name = "Pineapple"; // Give the mesh a name "Pineapple" + //give pineapple a position + + //explain the position of the mesh + //x,y,z? + //x: left to right + //y: up and down + //z: forward and backward + + + pineappleMesh.position = new BABYLON.Vector3(0, 0, 10); + + // Function to generate key items + + + // Generate keys using sine wave + var keys = []; + for (var frame = 0; frame <= 200; frame++) { + if (frame < 40) { + var value = Math.max(Math.sin(frame * Math.PI / 10) * 0.5, 0); // Adjust the amplitude and frequency as needed + keys.push(generateKey(frame, value)); + } else { + keys.push(generateKey(frame, 0.1)); + } + } + // console.log(keys); + + // Add bouncing animation to pineapple + var animationBox = new BABYLON.Animation("myAnimation", "position.y", 15, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE); + animationBox.setKeys(keys); + pineappleMesh.animations = []; + pineappleMesh.animations.push(animationBox); + scene.beginAnimation(pineappleMesh, 0, 200, true); + + + //what if we have enough of Pineapple? + //we give it a animation to bounce towards the actor and then let it disappear + document.addEventListener('keypress', function (event) { + if (event.key === '1') { + var keysZ = []; // Keyframes for the z-axis + var keysY = []; // Keyframes for the y-axis + var originalPositionZ = pineappleMesh.position.z; // Get current Z position + var originalPositionY = pineappleMesh.position.y; // Get current Y position + var bounceDistanceZ = 10; // Distance to bounce towards actor on z-axis + var bounceHeightY = 5; // Maximum height of the bounce on y-axis + + //get z and y position of the first mesh (the actor) + var actorMesh = pineappleResult.meshes[0]; + var actorPositionZ = actorMesh.position.z; + var actorPositionY = actorMesh.position.y; + + //stop any animation of pineapple + scene.stopAnimation(pineappleMesh); + + + // Bounce towards position z = 0 and make it "fly" on y-axis + for (var frame = 0; frame <= 40; frame++) { + var zValue = originalPositionZ - (Math.sin(frame * Math.PI / 80) * bounceDistanceZ); + var yValue = originalPositionY + (Math.sin(frame * Math.PI / 40) * bounceHeightY); // Sine wave for smooth up and down motion + keysZ.push({ frame: frame, value: zValue }); + keysY.push({ frame: frame, value: yValue }); + } + + // Set the final position at z = 0 and y returning to original + keysZ.push({ frame: 40, value: actorPositionZ }); + keysY.push({ frame: 40, value: actorPositionY }); + + // Create the animation for z-axis + var animationZ = new BABYLON.Animation( + "bounceToActorZ", + "position.z", + 40, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT + ); + + // Create the animation for y-axis + var animationY = new BABYLON.Animation( + "bounceToActorY", + "position.y", + 40, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT + ); + + animationZ.setKeys(keysZ); + animationY.setKeys(keysY); + + pineappleMesh.animations = [animationZ, animationY]; // Set the new animations + scene.beginAnimation(pineappleMesh, 0, 40, false); // Start animation with no looping + + // Make the pineapple disappear after the animation + // Observable to detect when the frame 40 is reached + var observer = scene.onBeforeRenderObservable.add(() => { + if (scene.getAnimationRatio() * 40 >= 40) { + //change pineapple in another mesh + + + scene.onBeforeRenderObservable.remove(observer); // Remove observer to avoid repeated execution + pineappleMesh.dispose(); // Remove the mesh from the scene + console.log("Pineapple mesh has been removed from the scene."); + } + }); + } + }); + } + + // var pineappleLight = new BABYLON.PointLight("pineappleLight", pineappleResult.meshes[0].getAbsolutePosition().add(new BABYLON.Vector3(0, 4, 0)), scene); + // pineappleLight.diffuse = new BABYLON.Color3(1, 1, 1); // Set light color + // pineappleLight.intensity = 1; // Set light intensity + //🍍 +}; + +// For testing purposes +//module.exports = { createScene, loadAssetMesh, rotateMesh180, setLightOnMesh, localAxes, hipsFrontAxes, generateKey, createPineapple }; diff --git a/media/babylon/ScreenRecorder.js b/media/babylon/ScreenRecorder.js new file mode 100644 index 000000000..b471ff75d --- /dev/null +++ b/media/babylon/ScreenRecorder.js @@ -0,0 +1,63 @@ +let mediaRecorder; +let recordedChunks = []; + +async function startRecording(canvasId, animFilename) { + return new Promise((resolve, reject) => { + const canvas = document.getElementById(canvasId); + const stream = canvas.captureStream(60); // Capture at 30 frames per second + + recordedChunks = []; + const options = { + mimeType: 'video/webm', + videoBitsPerSecond: 8000000, // Set video bitrate to 8 Mbps + width: 1920, // Set video width to 1920 pixels + height: 1080 // Set video height to 1080 pixels + }; + + mediaRecorder = new MediaRecorder(stream, options); + + mediaRecorder.ondataavailable = function(event) { + if (event.data.size > 0) { + recordedChunks.push(event.data); + } + }; + + mediaRecorder.onstop = function() { + onSaveRecording(animFilename); // Pass the animFilename to the onSaveRecording function + }; + + mediaRecorder.start(); + + mediaRecorder.onstart = () =>{ + + resolve(); + + } + }); +} + +async function stopRecording() { + if (mediaRecorder) { + mediaRecorder.stop(); + console.log('Recording stopped'); + } +} + +function onSaveRecording(animFilename) { + const blob = new Blob(recordedChunks, { type: "video/webm" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + + const filenameWithoutExtension = animFilename.replace(/\.glb$/, ''); + + a.download = filenameWithoutExtension + ".webm"; + + a.href = url; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +// Attach the functions to the window object to make them accessible from other scripts +window.startRecording = startRecording; +window.stopRecording = stopRecording; diff --git a/media/babylon/animFetchAndDestroy.js b/media/babylon/animFetchAndDestroy.js new file mode 100644 index 000000000..6bdf49edf --- /dev/null +++ b/media/babylon/animFetchAndDestroy.js @@ -0,0 +1,165 @@ +// Import animations for BabylonJS +async function getAnims(basePath, scene, loadedResults, glos, gltf, fullPath = false) { + if (!scene) { + console.error("Scene is undefined. Unable to import animations."); + return false; + } + + if (fullPath === false) { + console.log("Loading animations for " + glos + "..."); + } else { + console.log("Loading animations for " + basePath + "..."); + } + // Import animations asynchronously without auto-starting them + try { + if (!basePath) { + console.log("basePath is null"); + } + + if (!scene) { + console.log("scene is null"); + } + + if (!loadedResults) { + console.log("loadedResults is null"); + } + + if (!glos) { + console.log("glos is null"); + } + + const result = { + fetched: await BABYLON.SceneLoader.ImportAnimationsAsync( + basePath, + (fullPath === false ? glos + (gltf == 1 ? ".gltf" : ".glb") : ""), + scene, + false, + BABYLON.SceneLoaderAnimationGroupLoadingMode.NoSync, + null), + animationGroups: [], + lockRotHips: function () { + // for the anim, disable the hips rotationQuaternion animation and rotate the mesh 180 degrees + this.animationGroups.forEach(group => { + if (group !== null) { + group.targetedAnimations.forEach(targetedAnim => { + if (targetedAnim.target !== null && targetedAnim.animation !== null) { + if (targetedAnim.target.name === "Hips") { + if (targetedAnim.animation.targetProperty === "rotationQuaternion") { + targetedAnim.animation._keys.forEach(key => { + key.value.x = 0; + key.value.y = 0; + key.value.z = 1; + }); + + console.log("Hips rotation disabled."); + } + } + } + }); + } + }); + } + }; + + if (!result.fetched || !result.fetched.animationGroups || result.fetched.animationGroups.length === 0) { + console.error("No animations found or unable to load animations."); + return false; + } + + // Find all animation groups, makes accessing it later easier + for (animGroup of result.fetched.animationGroups) { + result.animationGroups.push(animGroup); + } + + // make name of last fetched group the glos name + const lastIndex = result.animationGroups.length - 1; + result.animationGroups[lastIndex].name = glos; + + // // Empty the animationGroups array in loadedResults + // loadedResults.animationGroups = []; + // lastIndex = result.animationGroups.length - 1; + // result.animationGroups[lastIndex].glos = glos; + + // Add animations to the loadedResults's animation group + loadedResults.animationGroups = result.animationGroups; + console.log("Animations loaded for " + (fullPath ? basePath : glos)); + + if (ParamsManager.lockRot === true) { + result.lockRotHips(); + } + + return result; + } catch (error) { + console.error("Failed to load animations:", error); + return null; + } +} + +// Keep only a single animation group +// This function is not done yet, we cant remove animation groups while looping over the groups +function keepOnlyAnimationGroup(scene, animAsset, loadedMesh, groupName = "anim") { + function iterateAndDestroyAnimationGroups(animationGroups) { + // Remove all animation groups except the one with the specified name + for (let i = 0; i < animationGroups.length; i++) { + if (animationGroups[i] == null) { continue; } + + if (animationGroups[i].name !== groupName) { + console.log("Removing animation group: " + animationGroups[i].name); + animationGroups[i].dispose(); + animationGroups[i] = null; + } + } + } + + iterateAndDestroyAnimationGroups(scene.animationGroups); + iterateAndDestroyAnimationGroups(animAsset.animationGroups); + iterateAndDestroyAnimationGroups(loadedMesh.animationGroups); + + // After removing references, make sure that the array is cleaned up + scene.animationGroups = scene.animationGroups.filter((obj) => obj !== null); + animAsset.animationGroups = animAsset.animationGroups.filter((obj) => obj !== null); + loadedMesh.animationGroups = loadedMesh.animationGroups.filter((obj) => obj !== null); +} + +function removeAnims(scene, animHolder) { + console.log("Removing animations..."); + // Validate the input parameters + if (!scene) { + console.error("Scene is undefined. Unable to remove animations."); + return false; + } + + if (!animHolder) { + console.error("animHolder is undefined. Unable to remove animations."); + return false; + } + + if (!animHolder.animationGroups || animHolder.animationGroups.length === 0) { + console.error("No animation groups found and therefore already removed."); + return true; + } + + // remove all animations from the loaded mesh + animHolder.animationGroups.forEach(animationGroup => { + scene.stopAnimation(animationGroup); + animationGroup.dispose(); + animationGroup = null; + }); + + // Remove all animations from the scene + scene.animationGroups.forEach(animationGroup => { + scene.stopAnimation(animationGroup); + animationGroup.dispose(); + animationGroup = null; + }); + + animHolder.animationGroups = null; + scene.animationGroups = null; + animHolder.animationGroups = []; + scene.animationGroups = []; + + console.log("All animations have been removed."); + return true; +} + +//module.exports = { getAnims, keepOnlyAnimationGroup, removeAnims }; diff --git a/media/babylon/cameraController.js b/media/babylon/cameraController.js new file mode 100644 index 000000000..cc8b7b69c --- /dev/null +++ b/media/babylon/cameraController.js @@ -0,0 +1,227 @@ +var CameraController = (function() { + // Private variables and functions + var camera; + var focusSphere; + var targetHips; + var forwardVec; + var distance; + var position = {x: null, y: null, z: null}; + var trackingHand = null; + + function setNearPlane(value) { + camera.minZ = value; + } + + function setAngleAlpha(angle) { + camera.alpha = BABYLON.Tools.ToRadians(angle); + } + + function setAngleBeta(angle) { + camera.beta = BABYLON.Tools.ToRadians(angle); + } + + function getAngleAlpha() { + return BABYLON.Tools.ToDegrees(camera.alpha); + } + + function getAngleBeta() { + return BABYLON.Tools.ToDegrees(camera.beta); + } + + function getFocusSphere() { + return focusSphere; + } + + function setPositionValues(x, y, z) { + position.x = x; + position.y = y; + position.z = z; + } + + function getPosition() { + console.log("Camera position: ", position); + return position; + } + + function setCameraParams(scene, cameraAngle, cameraAngleBeta, movingCamera) { + // cameraAngle is in degrees so we set it to radians + if (cameraAngle) { + setAngleAlpha(cameraAngle); + } + + // cameraAngleBeta is in degrees so we set it to radians + if (cameraAngleBeta) { + setAngleBeta(cameraAngleBeta); + } + + if (movingCamera) { + createCameraRotationAnimation(scene, 220, 340, 600); // Rotates from 0 to 180 degrees over 100 frames + + /* async function playLoadedAnims() { + if (scene && loaded) { + + await playAnims(scene, loaded, 0); + } else { + console.error('Scene or loaded variables are not set.');*/ + } + } + + function setCameraOnBone(scene, targetMesh, skeleton, boneIndex = 4, visualizeSphere = false, setLocalAxis = false) { + /* Creating a camera that we set to the position of the bone attached to the mesh's neck bone: + * 1. Create an empty object that we visualize as a sphere + * 2. Attach the sphere to the bone + * 3. Create a camera that we aim at the sphere + * 4. Profit + */ + + // Create a sphere that we attach to the bone + console.log("Setting camera on bone..."); + var sphere = BABYLON.Mesh.CreateSphere("sphere1", 16, 2, scene); + sphere.scaling = new BABYLON.Vector3(0.1, 0.1, 0.1); + sphere.attachToBone(skeleton.bones[boneIndex], targetMesh); + console.log("Sphere attached to bone: ", skeleton.bones[boneIndex].name); + console.log("Target mesh: ", targetMesh.name); + + // Set the position and rotation of the sphere relative to the bone + sphere.position = new BABYLON.Vector3(0, 0, 0); // Adjust according to your needs + sphere.rotation = new BABYLON.Vector3(0, 0, 0); // Adjust according to your needs + + + // Debugging funcs + sphere.setEnabled(visualizeSphere); + if (setLocalAxis) { + var sphere2 = BABYLON.Mesh.CreateSphere("sphere2", 16, 2, scene); + sphere2.scaling = new BABYLON.Vector3(0.4, 0.4, 0.4); + console.log("Sphere2: ", sphere2.forward); + localAxes(4, sphere2, scene); + localAxes(4, sphere, scene); + hipsFrontAxes(4, sphere, scene); + forwardVec = sphere.getChildren()[3]; + console.log("Forward vector: ", forwardVec); + } + + // Initializes an ArcRotateCamera named "camera1" in the scene. + // This camera is positioned to rotate around a target point defined by the vector (0, 0, -1). + // The 'alpha' parameter, set as Math.PI / -2, positions the camera at -90 degrees on the XZ plane, + // effectively placing it on the negative X-axis and facing towards the origin. + // The 'beta' parameter of 1 radian tilts the camera slightly downward from the vertical top view. + // The 'radius' parameter of 3 units sets the distance from the camera to the target point, placing it 3 units away. + + // This setup provides a unique side and slightly elevated view of the scene centered around the target point on the negative Z-axis. + camera.target = sphere; + focusSphere = sphere; + targetHips = skeleton.bones[0]; + }; + + function createCameraRotationAnimation(scene, startDegree, endDegree, duration) { + // Convert degrees to radians for the alpha property + var startRadians = BABYLON.Tools.ToRadians(startDegree); + var endRadians = BABYLON.Tools.ToRadians(endDegree); + + // Create a new animation object for the alpha property + var alphaAnimation = new BABYLON.Animation( + "alphaAnimation", + "alpha", + 30, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE // Loop mode + ); + + // Define key frames for the animation + var keys = []; + keys.push({ frame: 0, value: startRadians }); + keys.push({ frame: duration/2, value: endRadians }); + keys.push({ frame: duration, value: startRadians }); + alphaAnimation.setKeys(keys); + + // Add easing function for smooth animation (optional) + // var easingFunction = new BABYLON.CubicEase(); + // easingFunction.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT); + // alphaAnimation.setEasingFunction(easingFunction); + + // Apply the animation to the camera + camera.animations = []; + camera.animations.push(alphaAnimation); + + // Begin the animation + scene.beginAnimation(camera, 0, 600, true, 1); + }; + + function zoom(distanceFromBone) { + camera.radius = distanceFromBone; + }; + + function resetZoom() { + camera.radius = CameraController.distance-1; + camera.beta = Math.PI/2.5; + camera.alpha = Math.PI / -2; + }; + + // Public interface + return { + setCameraParams: setCameraParams, + setCameraOnBone: setCameraOnBone, + setAngleAlpha: setAngleAlpha, + setAngleBeta: setAngleBeta, + getAngleAlpha: getAngleAlpha, + getAngleBeta: getAngleBeta, + setNearPlane: setNearPlane, + getFocusSphere: getFocusSphere, + setPositionValues: setPositionValues, + getPosition: getPosition, + zoom: zoom, + resetZoom: resetZoom, + getInstance: function(scene, canvas, distance=5) { + CameraController.distance = distance; + + if (!camera) { + console.log("Initializing camera instance..."); + // Parameters: name, alpha, beta, radius, target position, scene + camera = new BABYLON.ArcRotateCamera("camera1", Math.PI / -2, 1, distance, new BABYLON.Vector3(0, 0, 0), scene); + camera.attachControl(canvas, true); + camera.wheelPrecision = 50; //Mouse wheel speed + + // Set the camera's position + const alpha = camera.alpha; + const beta = camera.beta; + const radius = camera.radius; + const target = camera.target; + + // Calculate camera position in world space + const x = target.x + radius * Math.cos(beta) * Math.sin(alpha); + const y = target.y + radius * Math.sin(beta); + const z = target.z + radius * Math.cos(beta) * Math.cos(alpha); + + const cameraPosition = new BABYLON.Vector3(x, y, z); + + setPositionValues(cameraPosition.x, cameraPosition.y, cameraPosition.z); + } + + // Attach pointer events to the scene, we dont want to interact with the gui when we are rotating the camera + if (scene) { + scene.onPointerDown = (e) => { + rootContainer.isEnabled = false; + } + + scene.onPointerUp = (e) => { + rootContainer.isEnabled = true; + } + } else { + console.error("Scene is not defined. Cannot attach pointer events."); + } + + CameraController.camera = camera; + return camera; + } + }; +})(); + +function addAngle(angle) { + console.log(CameraController.getAngleAlpha()); + CameraController.setAngleAlpha(CameraController.getAngleAlpha() + angle); +} +function getAngle() { + // CameraController.logTargetHipsAngles(); + CameraController.closest(); +} + diff --git a/media/babylon/eyeBlink.js b/media/babylon/eyeBlink.js new file mode 100644 index 000000000..3d8c5000e --- /dev/null +++ b/media/babylon/eyeBlink.js @@ -0,0 +1,76 @@ +function createBlinkAnimation(loadedResults) { + if (!loadedResults) { + console.log("loadedResults is null"); + } + + console.log("Creating blink animation..."); + + function createBlinkAnimation(morph1, morph2) { + const blinkAnimation = new BABYLON.Animation( + "blinkAnimation", + "influence", + 30, // FPS + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE + ); + + // Keyframes for the blink animation with varied timing + const blinkKeys = []; + + // Initial state: eyes open + blinkKeys.push({ frame: 0, value: 0 }); + + // First blink (quick) + blinkKeys.push({ frame: 10, value: 0 }); // Eyes closed + blinkKeys.push({ frame: 15, value: 1 }); // Eyes closed + blinkKeys.push({ frame: 20, value: 0 }); // Eyes open + + // Pause (slightly longer) + blinkKeys.push({ frame: 140, value: 0 }); // Eyes open + + // Double blink + blinkKeys.push({ frame: 145, value: 0 }); // Eyes Open + blinkKeys.push({ frame: 150, value: 1 }); // Eyes closed + blinkKeys.push({ frame: 155, value: 0 }); // Eyes open + blinkKeys.push({ frame: 170, value: 0 }); // Eyes open + blinkKeys.push({ frame: 175, value: 1 }); // Eyes closed + blinkKeys.push({ frame: 180, value: 0 }); // Eyes open + + // Long pause + blinkKeys.push({ frame: 280, value: 0 }); // Eyes open + + blinkAnimation.setKeys(blinkKeys); + + // Apply the animation to both eye morph targets + morph1.animations = [blinkAnimation]; + morph2.animations = [blinkAnimation]; + } + + loadedResults.morphTargetManagers.forEach(morphTargetManager => { + const morph1 = morphTargetManager.getTarget(57); + const morph2 = morphTargetManager.getTarget(58); + createBlinkAnimation(morph1, morph2); + + // Play the blink animation + scene.beginAnimation(morph1, 0, morph1.animations[0].getKeys().at(-1).frame, true); + scene.beginAnimation(morph2, 0, morph2.animations[0].getKeys().at(-1).frame, true); + }); +} + +function removeEyeBlinkAnimation(loadedResults) { + console.log("Removing eye blink animation..."); + + // Stop and remove the blink animation called blinkAnimation manually from both eye morph targets + loadedResults.morphTargetManagers.forEach(morphTargetManager => { + const morph1 = morphTargetManager.getTarget(57); + const morph2 = morphTargetManager.getTarget(58); + + // Stop the animation + scene.stopAnimation(morph1); + scene.stopAnimation(morph2); + + // Remove the animation + morph1.animations = []; + morph2.animations = []; + }); +} \ No newline at end of file diff --git a/media/babylon/initialize.js b/media/babylon/initialize.js new file mode 100644 index 000000000..3c673c841 --- /dev/null +++ b/media/babylon/initialize.js @@ -0,0 +1,241 @@ +const ParamsManager = { + local: null, + play: null, + limit: null, + glos: null, + zin: null, + gltf: null, + animations: [], + debug: false, + lockRot: false, + showGui: true, + returnToIdle: true, // If for some reason later on we dont want to return to idle, we can set this to false + startingNewAnim: false, // If we are starting a new animation, we can set this to true (to prevent race conditions) + boneLock: 4, + onboard: true, + eyeBlink: true, + meshURL: null, + + setParams(local, play, limit, glos, zin, gltf, debug, lockRot, noGui, onboard) { + // No babylon database storage when testing locally + if (local !== 1) { + BABYLON.Database.IDBStorageEnabled = true; + } + + this.local = local; + this.play = play !== undefined ? play : true; // Set play if we have play, else default to true + this.limit = limit !== null ? limit : 5; // Set limit if we have limit, else default to 5 + this.glos = glos !== null ? glos : "idle"; // Set glos if we have glos, else default to "idle" + this.zin = zin; + this.gltf = gltf; + this.debug = debug === undefined ? false : ((debug === "1" || debug === "true") ? true : false); + this.lockRot = lockRot === undefined ? false : ((lockRot === "1" || lockRot === "true") ? true : false); + this.showGui = noGui === undefined ? true : ((noGui === "1" || noGui === "true") ? false : true); + this.onboard = onboard === undefined ? true : ((onboard === "0" || onboard === "false") ? false : true); + this.meshURL = meshURL === undefined ? null : meshURL; + + if (zin) { + // We want to adjust frame from and to for blending + AnimationSequencer.setFrom(80); + AnimationSequencer.setTo(100); + + // Split zin with , and return as array + this.animations = zin.split(","); + console.log("Animations zin loaded:", this.animations); + } else { + // We want to adjust frame from and to for blending + AnimationSequencer.setFrom(30); + AnimationSequencer.setTo(30); + + console.log(loadSignCollectLabels(this.local, thema, this.limit, this.animations).then((animations) => { + console.log("Animations loaded:", animations); + })); + } + + this.gltf = gltf; + + return [this.local, this.play, this.limit, this.glos, this.zin, this.gltf, this.animations]; + } +}; + +// singleton for engine related items +const EngineController = (function () { + let engine = null; + let scene = null; + let loadedMesh = null; + + function setEngine(newEngine) { + engine = newEngine; + } + + function getEngine() { + return engine; + } + + function setScene(newScene) { + scene = newScene; + } + + function getScene() { + return scene; + } + + function setLoadedMesh(newLoadedMesh) { + loadedMesh = newLoadedMesh; + } + + function getLoadedMesh() { + return loadedMesh; + } + + return { + set engine(newEngine) { + setEngine(newEngine); + }, + get engine() { + return getEngine(); + }, + set scene(newScene) { + setScene(newScene); + }, + get scene() { + return getScene(); + }, + set loadedMesh(newLoadedMesh) { + setLoadedMesh(newLoadedMesh); + }, + get loadedMesh() { + return getLoadedMesh(); + } + }; +})(); + + +// Usage: +// ParamsManager.setParams(local, play, limit, glos, zin, gltf, animations); + +// function setParams(local, play, limit, glos, zin, gltf, animations) { +// // No babylon database storage when testing locally +// if (local != 1) { +// BABYLON.Database.IDBStorageEnabled = true; +// } + +// if (!play) { +// play = true; +// } + +// if (!limit) { +// limit = 5; +// } + +// if (glos == null) { +// glos = "ERROR-SC"; +// } + +// if (zin) { +// // We want to adjust frame from and to for blending +// AnimationSequencer.setFrom(80); +// AnimationSequencer.setTo(100); + +// //split zin with , and return as array +// animations = zin.split(","); +// } +// else { +// //we want to adjust frame from and to for blending +// AnimationSequencer.setFrom(30); +// AnimationSequencer.setTo(30); + +// animations = loadSignCollectLabels(local, thema, limit, animations); +// } + +// return [local, play, limit, glos, zin, gltf, animations]; +// } + +async function initialize(scene, engine, canvas, basePath, basePathMesh, loadedMesh, cameraAngle, cameraAngleBeta, movingCamera, boneLock=4, blending=false, animRotation=null) { + [scene, engine] = await createScene( + document.getElementById("renderCanvas") + ); + + if (ParamsManager.meshURL) { + // Split meshURL on last / and get the last element for filename and the rest for basePath + const meshURLSplit = ParamsManager.meshURL.split("/"); + var meshName = meshURLSplit.pop(); + var meshPath = meshURLSplit.join("/") + "/"; + + // If meshPath misses http:// or https://, add it + if (!meshPath.includes("http://") && !meshPath.includes("https://")) { + meshPath = "http://" + meshPath; + } + loadedMesh = await loadAssetMesh(scene, meshPath, filename=meshName, bugger=ParamsManager.debug); + } else { + loadedMesh = await loadAssetMesh(scene, basePathMesh, filename="glassesGuyNew.glb", bugger=ParamsManager.debug); + } + // loadedMesh = await loadAssetMesh(scene, basePathMesh, filename="Nemu.glb", bugger=ParamsManager.debug); + // loadedMesh = await loadAssetMesh(scene); + + // for all meshes in disable frustum culling + loadedMesh.fetched.meshes.forEach(mesh => { + mesh.alwaysSelectAsActiveMesh = true; + }); + + // Make sure we have a boneLock, fetch it from the name Neck or neck + if (!boneLock) { + var bone = loadedMesh.fetched.skeletons[0].bones.find(bone => (bone.name === "Neck" || bone.name === "neck")); + + if (bone) { + boneLock = bone._index; + } else { + boneLock = 4; + } + + ParamsManager.boneLock = boneLock; + } + + + // Create first camera, then access it through the singleton + var camera = CameraController.getInstance(scene, canvas); + camera.lowerRadiusLimit = 0.5; + camera.upperRadiusLimit = 6; + camera.inputs.removeByType("ArcRotateCameraKeyboardMoveInput"); + CameraController.setNearPlane(0.1); + // CameraController.setCameraOnBone(scene, loadedMesh.root, loadedMesh.skeletons[0], boneIndex=boneLock); + CameraController.setCameraOnBone(scene, loadedMesh.fetched.meshes[1], loadedMesh.skeletons[0], boneIndex=boneLock, visualizeSphere=ParamsManager.debug, setLocalAxis=ParamsManager.debug); + CameraController.setCameraParams(scene, cameraAngle, cameraAngleBeta, movingCamera); + createPineapple(scene, basePathMesh, loadedMesh.root); + + // Run the render loop + engine.runRenderLoop(function () { + scene.render(); + document.addEventListener("fullscreenchange", () => { + // console.error("fullscreenchange"); + resizeLogic(); + }); + + window.addEventListener("resize", () => { + // console.error("resize"); + resizeLogic(); + }); + + }); + + // Resize the canvas when the window is resized + window.addEventListener("resize", function () { + engine.resize(); + }); + + AnimationSequencer.setBlending(blending); + + try { + if (animRotation !== null) { + // Convert the rotation from degrees to radians + let rotationY = BABYLON.Quaternion.RotationAxis(BABYLON.Axis.Y, BABYLON.Tools.ToRadians(animRotation)); + + // Apply the rotation quaternion to the mesh + loadedMesh.god.rotationQuaternion = rotationY; + } + } catch (error) { + console.error("An error occurred while applying the rotation:", error); + } + + return [scene, engine, loadedMesh]; +} diff --git a/media/babylon/messageUEServer.js b/media/babylon/messageUEServer.js new file mode 100644 index 000000000..053c4a355 --- /dev/null +++ b/media/babylon/messageUEServer.js @@ -0,0 +1,187 @@ +function sendMessageUEProxy(messageType, messageContent) { + return new Promise((resolve, reject) => { + // Construct the POST request to the Flask server + console.log(`Sending message: ${JSON.stringify({ messageType, messageContent })}`); + fetch('/proxy_retarget', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + messageType: messageType, + messageContent: messageContent + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + console.log(`Received from Flask: ${data.response}`); + resolve(data.response); + } else { + console.error(`Error from Flask: ${data.message}`); + reject(new Error(data.message)); + } + }) + .catch(error => { + console.error('Fetch error:', error); + reject(new Error('Fetch error: ' + error.message)); + }); + }); +} + +function sendMessageUE(messageType, messageContent, host = retargetServerHost, port = retargetServerPort) { + return new Promise((resolve, reject) => { + // WebSocket connection + console.log(`Connecting to ws://${host}:${port}`); + const socket = new WebSocket(`ws://${host}:${port}`); + var response = null; + + // Connection opened + socket.addEventListener('open', function (event) { + console.log('WebSocket connected'); + + // Create the message string + const message = `${messageType}:${messageContent}`; + + // Send the message + socket.send(message); + console.log(`Sent message: ${message}`); + }); + + // Listen for messages from the server + socket.addEventListener('message', function (event) { + console.log(`Received: ${event.data}`); + response = event.data; // Resolve the promise with received data + }); + + // Listen for connection close + socket.addEventListener('close', function (event) { + console.log('Connection closed'); + resolve(response); + }); + + // Listen for errors + socket.addEventListener('error', function (event) { + console.error('WebSocket error:', event); + reject(new Error('WebSocket error')); + }); + }); +} +/** + * Sends a command to import a mesh in Unreal Engine. + * + * @param {string} meshURL - The URL of the mesh to import. + * @returns {Promise} - A promise that resolves to true if the command is successfully sent, or false if there is an error. + */ +function sendCommandUEMesh(meshURL) { + const meshName = meshURL.split('/').pop().split('.')[0]; + + return sendMessageUEProxy('import_fbx_from_url', meshURL) + .then(meshPath => { + if (!meshPath.includes("path")) { + throw new Error("Invalid response, meshPath does not contain 'path'\tResponse: " + meshPath); + } + + // Response form "File downloaded successfully path(/usr/src/your_project/imports/ERROR-SC.fbx)." Only the path is needed + meshPath = meshPath.split(' ').pop().slice(0, -2); + // Remove path( from the beginning + meshPath = meshPath.slice(5); + return sendMessageUEProxy('import_fbx', meshPath); + }) + .then(meshPathUE => { + return true; + }) + .catch(error => { + console.error("Error in sendCommandUEMeshRig:", error); + return false; + }); +} + +function sendCommandUEAnim(animURL, skeletonPath = null) { + const animName = animURL.split('/').pop().split('.')[0].replace('.fbx', ''); + const UEDestPath = `/Game/ImportedAssets/`; + + return sendMessageUEProxy('import_fbx_from_url', animURL) + .then(animPath => { + console.log("animPath:", animPath) + // Response form "File downloaded successfully path(/usr/src/your_project/imports/ERROR-SC.fbx)." Only the path is needed + animPath = animPath.split(' ').pop().slice(0, -2); + // Remove path( from the beginning + animPath = animPath.slice(5); + if (skeletonPath) { + return sendMessageUEProxy('import_fbx_animation', animPath + "," + UEDestPath + "," + animName + "," + skeletonPath); + } + + return sendMessageUEProxy('import_fbx_animation', animPath + "," + UEDestPath + "," + animName); + }) + .then(res => { + return true; + }) + .catch(error => { + console.error("Error in sendCommandUEAnim:", error); + return false; + }); +} + +function sendCommandUEAnimRetargetSend(sourceMeshPath, targetMeshPath, animPath, sendPath) { + console.log("sendCommandUEAnimRetargetSend", sourceMeshPath, targetMeshPath, animPath, sendPath); + // Make sure animPath is a string containing /Game/ImportedAssets/ + if (typeof animPath !== 'string' || !animPath.includes('/Game/ImportedAssets/')) { + return Promise.reject(new Error("animPath must be a string containing /Game/ImportedAssets/")); + } + + return sendMessageUEProxy('rig_retarget_send', sourceMeshPath + "," + targetMeshPath + "," + animPath + "," + sendPath) + .then(sourceMeshPathUE => { + return true; + }) + .catch(error => { + console.error("Error in sendCommandUEAnimRetarget:", error); + return false; + }); +} + +/** + * Helper function to execute the entire process: import source and target meshes, + * import an animation, retarget the animation, and send it to the specified endpoint. + * + * @param {string} sourceMeshURL - The URL of the source mesh to import. + * @param {string} targetMeshURL - The URL of the target mesh to import. + * @param {string} animURL - The URL of the animation to import. + * @param {string} sendPath - The URL to send the retargeted animation to. + * @param {string|null} sourceMeshPath - (Optional) The source mesh path in Unreal Engine. Defaults based on sourceMeshURL. + * @param {string|null} targetMeshPath - (Optional) The target mesh path in Unreal Engine. Defaults based on targetMeshURL. + * @param {string|null} skeletonPath - (Optional) The path to the skeleton in Unreal Engine. Defaults based on sourceMeshPath. + * @returns {Promise} - A promise that resolves to true if all steps succeed, or false if any step fails. + */ +function retargetUE(sourceMeshURL, targetMeshURL, animURL, sendPath, sourceMeshPath = null, targetMeshPath = null, skeletonPath = null) { + const UEDestPath = `/Game/ImportedAssets/`; + const animName = animURL.split('/').pop().split('.')[0].replace('.fbx', ''); + + // Derive mesh names from the URLs + const sourceMeshName = sourceMeshURL.split('/').pop().split('.')[0]; + const targetMeshName = targetMeshURL.split('/').pop().split('.')[0]; + + // Set default paths based on the mesh names if not provided + sourceMeshPath = sourceMeshPath || `/Game/SkeletalMeshes/${sourceMeshName}/${sourceMeshName}`; + targetMeshPath = targetMeshPath || `/Game/SkeletalMeshes/${targetMeshName}/${targetMeshName}`; + skeletonPath = skeletonPath || sourceMeshPath; // Default to sourceMeshPath if skeletonPath is not provided + + let animPath; + + // Import the source and target meshes, then the animation, then retarget and send + return sendCommandUEMesh(sourceMeshURL) + .then(() => sendCommandUEMesh(targetMeshURL)) + .then(() => sendCommandUEAnim(animURL, skeletonPath)) + .then(() => { + const fullAnimPath = `${UEDestPath}${animName}`; + return sendCommandUEAnimRetargetSend(sourceMeshPath, targetMeshPath, fullAnimPath, sendPath); + }) + .then(() => true) + .catch(error => { + console.error("Error in executeFullProcess:", error); + return false; + }); +} + +// Example usage +// sendCommandUEMeshRig('http://example.com/mesh.fbx'); diff --git a/media/babylon/noiseGenerator.js b/media/babylon/noiseGenerator.js new file mode 100644 index 000000000..7fb6d665d --- /dev/null +++ b/media/babylon/noiseGenerator.js @@ -0,0 +1,28 @@ +async function loadAndModifyAnimation(skeleton, mesh, noiseFactor, animationGroup) { + // Load the animation + + animationGroup.stop(); // Stop the animation if it's playing + + // Modify the animation keyframes + skeleton.bones.forEach(bone => { + bone.animations.forEach(animation => { + if (animation.targetProperty.includes("rotationQuaternion")) { + for (let key of animation.getKeys()) { + // Generate random rotation offsets + let randomYaw = Math.random() * noiseFactor; + let randomPitch = Math.random() * noiseFactor; + let randomRoll = Math.random() * noiseFactor; + let randomQuaternion = BABYLON.Quaternion.RotationYawPitchRoll(randomYaw, randomPitch, randomRoll); + + // Combine the original quaternion with the new random quaternion + let originalQuaternion = key.value; + let combinedQuaternion = originalQuaternion.multiply(randomQuaternion); + key.value = combinedQuaternion; + } + } + }); + }); + + // Play the modified animation + animationGroup.start(true, 1.0, animationGroup.from, animationGroup.to, false); +} diff --git a/media/babylon/onboard.js b/media/babylon/onboard.js new file mode 100644 index 000000000..1db26ce51 --- /dev/null +++ b/media/babylon/onboard.js @@ -0,0 +1,75 @@ +function onboardAnimation() { + // Create an image and add it to the GUI + const fingerImage = new BABYLON.GUI.Image("finger", images + "/finger.svg"); + var percentage = window.innerWidth * 0.06; + fingerImage.width = percentage + "px"; + fingerImage.height = percentage + "px"; + fingerImage.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_CENTER; + fingerImage.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER; + gui.addControl(fingerImage); + + // Create the shake animation + const dragAnimation = new BABYLON.Animation( + "dragAnimation", + "left", + 30, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE + ); + + // Keyframes for the shaking effect + const shakeKeys = []; + shakeKeys.push({ frame: 0, value: 200 }); + shakeKeys.push({ frame: 40, value: -200 }); + dragAnimation.setKeys(shakeKeys); + + // Apply the easing function + const easingFunction = new BABYLON.QuadraticEase(); + easingFunction.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT); + dragAnimation.setEasingFunction(easingFunction); + + // Create the rotation animation + const rotateAnimation = new BABYLON.Animation( + "rotateAnimation", + "rotation", + 30, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT + ); + + // Keyframes for the rotation effect + const rotateKeys = []; + rotateKeys.push({ frame: 0, value: BABYLON.Tools.ToRadians(15) }); // Start with a slight rotation + rotateKeys.push({ frame: 20, value: BABYLON.Tools.ToRadians(15) }); // Straighten by halfway + rotateKeys.push({ frame: 40, value: BABYLON.Tools.ToRadians(0) }); // Remain straight + + rotateAnimation.setKeys(rotateKeys); + + // Apply the easing function to the rotation animation + rotateAnimation.setEasingFunction(easingFunction); + + // Create the fade-in and fade-out animation (opacity) + const fadeAnimation = new BABYLON.Animation( + "fadeAnimation", + "alpha", + 30, + BABYLON.Animation.ANIMATIONTYPE_FLOAT, + BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT + ); + + const fadeKeys = []; + fadeKeys.push({ frame: 0, value: 0 }); // Start fully transparent + fadeKeys.push({ frame: 10, value: 1 }); // Fully visible by frame 10 + fadeKeys.push({ frame: 30, value: 1 }); // Stay fully visible until frame 30 + fadeKeys.push({ frame: 40, value: 0 }); // Fade out by frame 40 + + fadeAnimation.setKeys(fadeKeys); + + + // Apply and start the animation + fingerImage.animations = [dragAnimation, rotateAnimation, fadeAnimation]; + scene.beginAnimation(fingerImage, 0, 40, false, 1, function() { + // Remove the image after the animation is complete + gui.removeControl(fingerImage); + }); +} diff --git a/media/babylon/playAnims.js b/media/babylon/playAnims.js new file mode 100644 index 000000000..80d86e207 --- /dev/null +++ b/media/babylon/playAnims.js @@ -0,0 +1,511 @@ +//Play the animation that is currently loaded +async function playLoadedAnims(scene, loaded) { + if (scene && loaded) { + // Initialize animation with standard vars, start frame + 30 frames, end frame - 30 frames + await initializeAnimationGroups(loaded); + + if (keepAnimating) { + await playAnimationForever(scene, loaded); + } else { + await playAnims(scene, loaded, 0); + } + } else { + console.error("Scene or loaded variables are not set."); + } +} + +async function playAnimationForever(scene, loaded) { + await playAnims(scene, loaded, 0) // 1 comes from loaded animation of gloss, 0 comes from baked-in animation of avatar.glb + .then(animationPlayed => { + if (animationPlayed) { + // Replay the animation after 1 second timeout and after it stopped + playAnimationForever(scene, loaded); + } + }) + .catch(err => { + console.error("Error playing animation:", err); + }); +} + +// Define an object to encapsulate animation sequencing logic +// That way, we can have the starting and stopping of the animation in this file instead +// of the main file. And we dont need to use a global continueloop variable. +const AnimationSequencer = (function () { + let continueLoop = false; + let notSequencing = false; + let animationGroupFrom = 80; + let animationGroupTo = 100; + let blending = true; + + async function startAnimationLoop(basePath, scene, loadedMesh, animations, recording=false, recordingMethod="", keepPlaying=false) { + continueLoop = true; + console.log("recording: ", recording); + + // Load and initialize animations + if (await preloadAndInitializeAnimations(basePath, scene, loadedMesh, animations)) { + console.log("All animations preloaded and initialized."); + await hideModal(); + + recordingFile = zinArray.join(' '); + if (recording && recordingMethod == "zin") await startRecording('renderCanvas', recordingFile); // Start recording; + + for (let i = 0; i < loadedMesh.animationGroups.length; i++) { + if (!continueLoop) break; + + glos = loadedMesh.animationGroups[i].name; + + if (recording && recordingMethod != "zin") { + console.log("recording with method: ", recording, recordingMethod, glos); + await startRecording('renderCanvas', glos); // Start recording; + } + + console.log(`Now playing: ${glos}`); + if (await playAnims(scene, loadedMesh, i)) { + console.log(`Playing complete: ${glos}`); + // if (recording == "glos") { stopRecording(); } + if (recording != "zin") { stopRecording(); } + } else { + console.log(`Failed to play animation: ${glos}`); + } + + if (i == loadedMesh.animationGroups.length - 1 && keepPlaying == false) { + keepAnimating = true; + playLoadedAnims(scene, loadedMesh); + } + else if (i == loadedMesh.animationGroups.length - 1 && keepPlaying == true) { + if (recordingMethod == "zin" && recording) stopRecording(); + recording = false; + //here i want to restart the for loop to 0 + i = -1; + console.log("restart") + } + } + } else { + console.error("Failed to preload and initialize animations."); + } + + return; + } + + function stopAnimationLoop() { + continueLoop = false; + } + + function setBlending(value) { + blending = value; + } + + function getBlending() { + return blending; + } + + function setAnimationGroupFrom(value) { + animationGroupFrom = value; + } + + function setAnimationGroupTo(value) { + animationGroupTo = value; + } + + function getAnimationGroupFrom() { + return animationGroupFrom; + } + + function getAnimationGroupTo() { + return animationGroupTo; + } + + function sequencing() { + return notSequencing; + } + + function setSequencing(value) { + notSequencing = value; + } + + return { + start: startAnimationLoop, + stop: stopAnimationLoop, + setFrom: setAnimationGroupFrom, + setTo: setAnimationGroupTo, + getFrom: getAnimationGroupFrom, + getTo: getAnimationGroupTo, + getSequencing: sequencing, + setSequencing: setSequencing, + getBlending: getBlending, + setBlending: setBlending + }; +})(); + +async function initializeAnimationGroups(loadedResults) { + console.log("Initializing animation groups..."); + loadedResults.animationGroups.forEach(animationGroup => { + if (!animationGroup.initialized && AnimationSequencer.getSequencing()) { + // animationGroup.normalize(0, 200); //messes up more + animationGroup.from += AnimationSequencer.getFrom(); // for estimation of frames, we would need tool of amit to determine mid frame + animationGroup.to -= AnimationSequencer.getTo(); + animationGroup.initialized = true; + //console.log("Retargeting animation group: " + animationGroup.name); + animationGroup.targetedAnimations.forEach((targetedAnimation) => { + //this can be used to inject seed noise for randomization in coordinates/rotation of bones + + // const newTargetBone = targetSkeleton.bones.filter((bone) => { return bone.name === targetedAnimation.target.name })[0]; + // // //console.log("Retargeting bone: " + target.name + "->" + newTargetBone.name); + // targetedAnimation.target = newTargetBone ? newTargetBone.getTransformNode() : null; + // console.log(targetedAnimation.target); + }); + } + }); + return true; +} + +async function playAnims(scene, loadedResults, animationIndex, loop = false, noRotation = false) { + // Validate the input parameters + if (!scene || !loadedResults || !loadedResults.animationGroups || loadedResults.animationGroups.length === 0) { + console.error("Invalid input. Unable to play animations."); + return false; + } + + // Check if eye blink is enabled, play the eye blink animation. + // Make sure to have this before other animations are played in the case we have morphs to overwrite + if (ParamsManager.eyeBlink) { + createBlinkAnimation(EngineController.loadedMesh); + } + + // Check the range of the animation index + if (animationIndex >= 0 && animationIndex <= loadedResults.animationGroups.length) { + // animationIndex -= 1; + const animationGroup = loadedResults.animationGroups[animationIndex]; + console.error(loadedResults.animationGroups); + + // Only blend if there are more than one animation groups + if (animationIndex + 1 != loadedResults.animationGroups.length && EngineController.blending) { + animationGroup.enableBlending = true; + } + animationGroup.normalize = true; + if (!animationGroup.targetedAnimations || animationGroup.targetedAnimations.some(ta => ta.target === null)) { + console.error("Animation target missing for some animations in the group:", animationGroup.name); + console.log("Removing targeted animations with missing targets."); + animationGroup.targetedAnimations = animationGroup.targetedAnimations.filter(ta => ta.target !== null); + } + + if (ParamsManager.showGui) { + animSlider(animationGroup, rootContainer, scene); + pausePlaySpeedButtons(animationGroup, rootContainer); + handTrackButtons(rootContainer); + hideShowGui(rootContainer, true); + } + + return new Promise((resolve) => { + animationGroup.onAnimationEndObservable.addOnce(() => { + console.log(`Animation in ${animationGroup.name} has ended.`); + scene.onBeforeRenderObservable.clear(); // Remove the observer to clean up + resolve(true); + if (ParamsManager.returnToIdle && !ParamsManager.startingNewAnim) { + stopLoadAndPlayAnimation(basePath + "idle.glb", loop=true, noRotation=true); + } + }); + + animationGroup.onAnimationGroupEndObservable.addOnce(() => { + console.log(`AnimationGROUP ${animationGroup.name} has ended.`); + EngineController.loadedMesh.hips.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.papa.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.opa.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.resetMorphs(); + EngineController.loadedMesh.skeletons[0].returnToRest(); + // EngineController.loadedMesh.hips.position = BABYLON.Vector3.Zero(); + hideShowGui(rootContainer, false); + }); + + animationGroup.onAnimationGroupPlayObservable.addOnce(() => { + console.log(`AnimationGROUP ${animationGroup.name} has started.`); + EngineController.loadedMesh.hips.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.papa.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.opa.rotationQuaternion = BABYLON.Quaternion.Identity(); + EngineController.loadedMesh.resetMorphs(); + console.error(EngineController.loadedMesh.morphTargetManagers); + // EngineController.loadedMesh.hips.position = BABYLON.Vector3.Zero(); + + // In animationGroup.targetedAnimations get target Hips + let animHipsRotation; + animationGroup.targetedAnimations.every(x => { + if (x.target.name === EngineController.loadedMesh.hips.name && x.animation.targetProperty === "rotationQuaternion") { + animHipsRotation = x.animation; + return false; + } + + return true; + }); + + if (!noRotation) { + // Get the initial rotation of the hips of the first frame of the animation and orient the mesh upwards + let initialRotation = animHipsRotation.getKeys().at(0).value; + const inverse = initialRotation.negateInPlace(); + + // Compare currentRotation with inverseRotation + const epsilon = 0.004; // Threshold for considering rotations as "equal" + if (Math.abs(initialRotation.x - inverse.x) > epsilon || + Math.abs(initialRotation.y - inverse.y) > epsilon || + Math.abs(initialRotation.z - inverse.z) > epsilon || + Math.abs(initialRotation.w - inverse.w) > epsilon) { + + // If the current rotation is not already the inverse, apply the inverse rotation + EngineController.loadedMesh.papa.rotationQuaternion = inverse; + + // Flip the papa object around the z axis + let tmp = EngineController.loadedMesh.papa.rotationQuaternion; + EngineController.loadedMesh.papa.rotationQuaternion = new BABYLON.Quaternion(-tmp.x, -tmp.y, tmp.z, tmp.w); + } + } + + // wait 0.1 seconds for the animation to start playing then rotate opa or not TODO: change this to a better solution without a wait + new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 100); + }).then(() => { + // Refocus the camera + // Check coordinate system by looking if hips are pointing forward + const hips = EngineController.loadedMesh.hips.rotationQuaternion; + // Check y rotation of hips + const hipsEuler = hips.toEulerAngles(); + console.log(hips); + console.log("Hips rotation in degrees: ", hipsEuler.scale(180/Math.PI).y); + // Floor the y rotation to the nearest 90 degrees + let zRotation = Math.abs(Math.round(hipsEuler.z * 2 / Math.PI) * Math.PI / 2); + console.log("Floored z rotation in degrees: ", zRotation * 180 / Math.PI); + // Check if the y rotation is close to 180 degrees, if so rotate opa 180 degrees over the y axis + if (Math.abs(zRotation - Math.PI) >= 0.1) { + console.log("Rotating opa 180 degrees."); + EngineController.loadedMesh.opa.rotationQuaternion = BABYLON.Quaternion.RotationAxis(BABYLON.Axis.Y, Math.PI); + } + console.error("zRotation: ", zRotation); + }); + }); + + // Play the animation + if (animationGroup === null) { + console.error("Animation group is null."); + } else if (animationGroup.from === null || animationGroup.to === null) { + // animationGroup.start(false, 1.0, loop=loop); + animationGroup.play(loop); + } else { + // animationGroup.start(false, 1.0, animationGroup.from, animationGroup.to, loop=loop); + animationGroup.play(loop); + } + // animationGroup.start(); + }); + } else { + console.error("Invalid animation index:", animationIndex, "for loadedResults.animationGroups.length:", loadedResults.animationGroups.length, "animations."); + return false; + } +} + +function stopAnims(scene, loadedResults) { + // Validate the input parameters + if (!scene) { + console.error("Scene is not set."); + console.log(scene); + return false; + } + + if (!loadedResults) { + console.error("loadedResults is not set."); + console.log(loadedResults); + return false; + } + + if (!loadedResults.animationGroups || loadedResults.animationGroups.length === 0) { + console.error("AnimGroups are not present and therefore already stopped."); + console.log(loadedResults.animationGroups); + console.log(loadedResults.animationGroups.length); + return true; + } + + removeEyeBlinkAnimation(EngineController.loadedMesh); + + // Stop animations on all meshes + loadedResults.fetched.meshes.forEach(mesh => { + scene.stopAnimation(mesh); + }); + + // If you need to stop animation groups as well + loadedResults.animationGroups.forEach(animationGroup => { + animationGroup.stop(); // Stops the animation group from playing + // For each animation in the group stop it as well + animationGroup.targetedAnimations.forEach(targetedAnimation => { + scene.stopAnimation(targetedAnimation.target); + }); + }); + + scene.animationGroups.forEach(animationGroup => { + animationGroup.stop(); // Stops the animation group from playing + }); + + console.log("All animations have been stopped."); + return true; +} + + +async function preloadAndInitializeAnimations(basePath, scene, loaded, animations) { + if (animations === null || animations.length === 0) { + console.error("No animations to preload."); + return false; + } + + console.log(animations) + for (let animName of animations) { + console.log(`Preloading animation: ${animName}`); + //add animName to modal + $('#errorModalLabel').append(`

${animName}

`); + let result = await getAnims(basePath, scene, loaded, animName); + if (!result) { + console.error(`Failed to preload animation: ${animName}`); + return false; + } + } + await initializeAnimationGroups(loaded); // Initialize all at once after loading + + return true; +} + +function signFetcher() { + disableControls(); + $("#glossModal").modal("show") +} + +function stopLoadAndPlayAnimation(path, loop = false, noRotation = false) { + console.log(path); + console.log(EngineController.scene); + + // Check if the path ends with 'idle.glb' + if (!path.endsWith('idle.glb')) { + ParamsManager.startingNewAnim = true; + } + + // Stop the currently playing animation + stopAnims(EngineController.scene, EngineController.loadedMesh); + + // Remove the currently loaded animation + removeAnims(EngineController.scene, loadedMesh); + removeAnims(EngineController.scene, scene); + + // Fetch the new animation and play + getAnims(path, EngineController.scene, EngineController.loadedMesh, ParamsManager.glos, ParamsManager.gltf, fullPath = true) + .then(anim => { + console.log("getAnims returned: ", anim); + anim.animationGroups.push(retargetAnimWithBlendshapes(EngineController.loadedMesh, anim.animationGroups[0], "freshAnim")); + // console.log(anim.animationGroups); + keepOnlyAnimationGroup(EngineController.scene, anim, EngineController.loadedMesh, "freshAnim"); + EngineController.loadedMesh.animationGroups = anim.animationGroups; + EngineController.scene.animationGroups = anim.animationGroups; + + + playAnims(EngineController.scene, EngineController.loadedMesh, 0, loop, noRotation=noRotation); + ParamsManager.startingNewAnim = false; + + // wait for the EngineController.loadedMesh.animationGroups[0].isPlaying to start playing then refocus camera + new Promise(resolve => { + const checkPlaying = setInterval(() => { + if (EngineController.loadedMesh.animationGroups[0].isPlaying) { + clearInterval(checkPlaying); + resolve(); + } + }, 1000); + }).then(() => { + // Code to execute after the animation starts playing + // Refocus the camera + // CameraController.reFocusCamera(); + }); + + }) + .catch(error => { + console.error('Failed to load animations:', error); + ParamsManager.startingNewAnim = false; + if (ParamsManager.returnToIdle) { + stopLoadAndPlayAnimation(basePath + "idle.glb", loop=true, noRotation=true); + } + $('#errorModal2 .modal-body').html(`

${error}

`); + $('#errorModal2').modal('show').css('z-index', 1065); + $('.modal-backdrop').css('z-index', 1064); + }); +} + +// function getClosestAxis(quat) { +// if (!quat) { +// console.error("Invalid quaternion passed to getClosestAxis."); +// return BABYLON.Quaternion.Identity(); // Return a default identity quaternion +// } + +// // Apply the initial rotation to the world up vector (0, 1, 0) +// let transformedUp; +// try { +// console.log("A"); + +// transformedUp = BABYLON.Vector3.TransformCoordinates(BABYLON.Vector3.Up(), quat); +// } catch (error) { +// console.log("B"); + +// console.error("Failed to transform coordinates:", error); +// return BABYLON.Quaternion.Identity(); +// } + +// // Find the axis with the largest component in the transformed up vector +// let absX = Math.abs(transformedUp.x); +// let absY = Math.abs(transformedUp.y); +// let absZ = Math.abs(transformedUp.z); + +// // Identify the closest world axis +// let closestAxis; +// if (absY >= absX && absY >= absZ) { +// // Closest to Y-axis +// closestAxis = BABYLON.Axis.Y; +// } else if (absX >= absY && absX >= absZ) { +// // Closest to X-axis +// closestAxis = BABYLON.Axis.X; +// } else { +// // Closest to Z-axis +// closestAxis = BABYLON.Axis.Z; +// } + +// // Create a quaternion that represents alignment with the closest axis +// let alignedQuat = BABYLON.Quaternion.Identity(); +// if (closestAxis === BABYLON.Axis.X) { +// alignedQuat = BABYLON.Quaternion.RotationAxis(BABYLON.Axis.X, Math.PI / 2); +// } else if (closestAxis === BABYLON.Axis.Y) { +// alignedQuat = BABYLON.Quaternion.RotationAxis(BABYLON.Axis.Y, 0); // Identity already represents this +// } else if (closestAxis === BABYLON.Axis.Z) { +// alignedQuat = BABYLON.Quaternion.RotationAxis(BABYLON.Axis.Z, Math.PI / 2); +// } + +// return alignedQuat; +// } + +/* +The following functions are deprecated and should not be used. +We are fetching this information on load of the model itself. + +// Get a list of loaded animations +function getLoadedAnimations(loadedResults) { + const loadedAnimations = []; + + // Store each animation group + loadedResults.animationGroups.forEach(animationGroup => { + loadedAnimations.push(animationGroup); + }); + + return loadedAnimations; +} + +// Get a list of loaded meshes +function getLoadedMeshes(loadedResults) { + const loadedMeshes = []; + + // Store each mesh + for (let mesh in loadedResults.meshes) { + loadedMeshes.push(loadedResults.meshes[mesh]); + } + + return loadedMeshes; +} +*/ diff --git a/media/babylon/playerControls.js b/media/babylon/playerControls.js new file mode 100644 index 000000000..5c84677f5 --- /dev/null +++ b/media/babylon/playerControls.js @@ -0,0 +1,402 @@ +// import '@babylonjs/gui' +// document.addEventListener("fullscreenchange", () => { +// console.error("fullscreenchange"); +// resizeLogic(); +// }); + +// window.addEventListener("resize", () => { +// console.error("resize"); +// resizeLogic(); +// }); + +// Flag to check if controls are enabled +let controlsEnabled = true; + +function disableControls() { + controlsEnabled = false; +} + +function enableControls() { + controlsEnabled = true; +} + + +function resizeLogic() { + gui.scaleTo(engine.getRenderWidth(), engine.getRenderHeight()); + if (engine.getRenderHeight() >= 580) { + gui.rootContainer.getChildByName("grid").getChildByName("animSlider").height = "2%"; + // gui.rootContainer.getChildByName("grid").getChildByName("playPause").top = "-3%"; + } else if (engine.getRenderHeight() < 220) { + gui.rootContainer.getChildByName("grid").getChildByName("animSlider").height = "8%"; + // gui.rootContainer.getChildByName("grid").getChildByName("playPause").top = "-7%"; + } else { + gui.rootContainer.getChildByName("grid").getChildByName("animSlider").height = "5%"; + // gui.rootContainer.getChildByName("grid").getChildByName("playPause").top = "-5%"; + } + + var percentage = window.innerWidth * 0.06; + gui.rootContainer.getChildByName("grid").getChildByName("playPause").width = percentage + "px"; + gui.rootContainer.getChildByName("grid").getChildByName("playPause").height = percentage / 2 + "px"; + gui.rootContainer.getChildByName("grid").getChildByName("handTracking").width = percentage + "px"; + gui.rootContainer.getChildByName("grid").getChildByName("handTracking").height = percentage / 2 + "px"; +} + +function createRootContainer(gui) { + var rootContainer = new BABYLON.GUI.Grid("grid"); + rootContainer.width = "100%"; + rootContainer.height = "100%"; + gui.addControl(rootContainer); + + return rootContainer; +} + +function animSlider(animationGroup, rootContainer, scene) { + // Remove previous controls + rootContainer.clearControls(); + console.log("animationGroup: ", animationGroup); + + var currGroup = animationGroup; + + // Add a slider to control the animation + var slider = new BABYLON.GUI.Slider("animSlider"); + slider.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; + slider.width = "50%"; + // Check if screen too small for slider 2% is minimum + if (engine.getRenderHeight() >= 580) { + slider.height = "2%"; + } else if (engine.getRenderHeight() < 220) { + slider.height = "8%"; + } else { + slider.height = "5%"; + } + slider.top = "-5%"; + slider.color = "white"; + slider.thumbWidth = "0%"; // Use percentage for thumb width + slider.isThumbCircle = true; + rootContainer.addControl(slider, 1); + rootContainer.animSlider = slider; + rootContainer.playing = true; + + slider.onValueChangedObservable.add(function (value) { + // header.text = ((value) | 0); + currGroup.goToFrame(value); + slider.minimum = currGroup.from; + slider.maximum = currGroup.to; + }); + + slider.onPointerDownObservable.add(() => { + animationGroup.pause(); + }); + + slider.onPointerUpObservable.add(() => { + if (rootContainer.playing) { + animationGroup.play(); + } + }); + + scene.onBeforeRenderObservable.add(() => { + if (currGroup) { + var ta = currGroup.targetedAnimations; + if (ta && ta.length) { + var ra = ta[0].animation.runtimeAnimations; + if (ra && ra.length) { + slider.value = ra[0].currentFrame; + } + } + } + }); +} + +function speedControlButton(animationGroup, playSpeedBtn) { + const speedLevels = [1, 0.1, 0.3, 0.5]; + let currentSpeedIndex = 0; + const selectedColor = new BABYLON.Color3(1/255, 150/255, 255/255).toHexString(); + const speedColor = new BABYLON.Color3(1/255, 255/255, 150/255).toHexString(); + + // Create the clickable button and add the speed icon + var clickable = new BABYLON.GUI.Button('clickable'); + clickable.width = "40%"; + clickable.height = "80%"; + clickable.background = "transparent"; + clickable.thickness = 0; + clickable.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; + + var speedImage = new BABYLON.GUI.Image("speedImage", images + "/speed.svg"); + speedImage.width = "100%"; + speedImage.height = "100%"; + speedImage.shadowColor = speedColor; + speedImage.shadowBlur = 1; + speedImage.shadowOffsetX = 3; + speedImage.shadowOffsetY = 2.5; + clickable.addControl(speedImage); + + var letter = new BABYLON.GUI.TextBlock(); + letter.text = "1"; + letter.color = "white"; + letter.fontSize = "50%"; + letter.resizeToFit = true; + letter.paddingRight = "10%"; + letter.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; + letter.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; + clickable.addControl(letter); + + + // Function to cycle through the speed levels + function cycleSpeed(direction) { + if (direction === "next") { + currentSpeedIndex = (currentSpeedIndex + 1) % speedLevels.length; + } else if (direction === "prev") { + currentSpeedIndex = (currentSpeedIndex - 1 + speedLevels.length) % speedLevels.length; + } + animationGroup.speedRatio = speedLevels[currentSpeedIndex]; + letter.text = speedLevels[currentSpeedIndex]; + updateSpeedDisplay(); + } + + // Function to update the button appearance based on the current speed + function updateSpeedDisplay() { + // Change icon color based on speed level + speedImage.shadowColor = currentSpeedIndex === 0 ? selectedColor : speedColor; + } + + // When the button is clicked, change the speed + clickable.onPointerClickObservable.add(() => cycleSpeed("next")); + + // Add event listener for keyboard shortcuts + window.addEventListener("keydown", function(event) { + if (!controlsEnabled) { return; } + + if (event.code === "KeyS") { + cycleSpeed("next"); + event.preventDefault(); + } else if (event.code === "ArrowRight") { + cycleSpeed("next"); + event.preventDefault(); + } else if (event.code === "ArrowLeft") { + cycleSpeed("prev"); + event.preventDefault(); + } + }); + + // Change the color of the button when the mouse hovers over it + clickable.pointerEnterAnimation = () => { + speedImage.shadowColor = "white"; + } + + // Change the color of the button when the mouse leaves it + clickable.pointerOutAnimation = () => { + updateSpeedDisplay(); + } + + + playSpeedBtn.addControl(clickable); + + // Initial update + updateSpeedDisplay(); +} + +function pausePlaySpeedButtons(animationGroup, rootContainer) { + var playColor = new BABYLON.Color3(1/255, 255/255, 150/255).toHexString(); + var pauseColor = new BABYLON.Color3(1/255, 150/255, 255/255).toHexString(); + + // Create the button container and set the position of the button based on the window size + const playSpeedBtn = new BABYLON.GUI.Container("playPause"); + playSpeedBtn.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; + playSpeedBtn.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; + var percentage = window.innerWidth * 0.06; + playSpeedBtn.width = percentage + "px"; + playSpeedBtn.height = percentage / 2 + "px"; + playSpeedBtn.left = "-15%"; + if (engine.getRenderHeight() >= 580) { + playSpeedBtn.top = "-3%"; + } else if (engine.getRenderHeight() < 220) { + playSpeedBtn.top = "-7%"; + } else { + playSpeedBtn.top = "-5%"; + } + playSpeedBtn.background = "transparent"; + + // Create the clickable button and add the play/pause image to it + var clickable = new BABYLON.GUI.Button('clickable'); + clickable.width = "40%"; + clickable.height = "80%"; + clickable.background = "transparent"; + clickable.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT; + clickable.thickness = 0; + + var playImage = new BABYLON.GUI.Image("playImage", images + "/pause.svg"); + playImage.width = "70%"; + playImage.height = "70%"; + playImage.shadowColor = pauseColor; + playImage.shadowBlur = 1; + playImage.shadowOffsetX = 3; + playImage.shadowOffsetY = 2.5; + clickable.addControl(playImage); + + // Function to handle play/pause logic + function togglePlayPause() { + if (animationGroup.isPlaying) { + animationGroup.pause(); + rootContainer.playing = false; + playImage.source = images + "/play.svg"; + } else { + animationGroup.play(); + rootContainer.playing = true; + playImage.source = images + "/pause.svg"; + } + } + + // When the button is clicked, pause or play the animation based on the current state + clickable.onPointerClickObservable.add(togglePlayPause); + + // Add event listener for the spacebar to toggle play/pause + window.addEventListener("keydown", function(event) { + if (!controlsEnabled) { return; } + + if (event.code === "Space") { + togglePlayPause(); + if (animationGroup.isPlaying) { + playImage.shadowColor = pauseColor; + } else { + playImage.shadowColor = playColor; + } + // Prevent default spacebar behavior (e.g., scrolling down) + event.preventDefault(); + } + }); + + // Change the color of the button when the mouse hovers over it + clickable.pointerEnterAnimation = () => { + playImage.shadowColor = "white"; + } + + // Change the color of the button when the mouse leaves it + clickable.pointerOutAnimation = () => { + if (animationGroup.isPlaying) { + playImage.shadowColor = pauseColor; + } else { + playImage.shadowColor = playColor; + } + } + + playSpeedBtn.addControl(clickable); + + // Get the speed control button and add it to the same container + speedControlButton(animationGroup, playSpeedBtn); + + rootContainer.addControl(playSpeedBtn); +} + +function handTrackButtons(rootContainer) { + var handTrackColor = new BABYLON.Color3(1/255, 150/255, 255/255).toHexString(); + var selectedColor = new BABYLON.Color3(1/255, 255/255, 150/255).toHexString(); + + const trackHandContainer = new BABYLON.GUI.Container("handTracking"); + trackHandContainer.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; + trackHandContainer.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; + var percentage = window.innerWidth * 0.06; + trackHandContainer.width = percentage + "px"; + trackHandContainer.height = percentage / 2 + "px"; + trackHandContainer.left = "-5%"; + + if (engine.getRenderHeight() >= 580) { + trackHandContainer.top = "-3%"; + } else if (engine.getRenderHeight() < 220) { + trackHandContainer.top = "-7%"; + } else { + trackHandContainer.top = "-5%"; + } + trackHandContainer.background = "transparent"; + + function createClickableButton(name, alignment, boneIndex, letterText) { + var clickable = new BABYLON.GUI.Button(name); + clickable.width = "40%"; + clickable.height = "80%"; + clickable.background = "transparent"; + clickable.horizontalAlignment = alignment; + clickable.thickness = 0; + trackHandContainer.addControl(clickable); + + var handImage = new BABYLON.GUI.Image(name + "Image", images + "/hand.svg"); + handImage.width = "100%"; + handImage.height = "100%"; + handImage.shadowColor = handTrackColor; + handImage.shadowBlur = 1; + handImage.shadowOffsetX = 3; + handImage.shadowOffsetY = 2.5; + clickable.addControl(handImage); + + var letter = new BABYLON.GUI.TextBlock(); + letter.text = letterText; + letter.color = "white"; + letter.fontSize = "50%"; + letter.resizeToFit = true; + letter.paddingRight = "10%"; + letter.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; + letter.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; + clickable.addControl(letter); + + clickable.pointerEnterAnimation = () => { + handImage.shadowColor = "white"; + } + + clickable.pointerOutAnimation = () => { + handImage.shadowColor = (CameraController.trackingHand == boneIndex) ? selectedColor : handTrackColor; + } + + clickable.onPointerClickObservable.add(() => { + // If already tracking, untrack the hand + if (CameraController.trackingHand == boneIndex) { + CameraController.trackingHand = null; + CameraController.setCameraOnBone(scene, loadedMesh.fetched.meshes[1], loadedMesh.skeletons[0], ParamsManager.boneLock); + handImage.shadowColor = handTrackColor; + CameraController.resetZoom(); + return; + } + // If tracking a different hand, switch to the new hand + else if (CameraController.trackingHand != null && CameraController.trackingHand != boneIndex) { + var otherHand = trackHandContainer.getChildByName(name == "clickableL" ? 'clickableR' : 'clickableL'); + var otherHandImage = otherHand.getChildByName(name == "clickableL" ? 'clickableRImage' : 'clickableLImage'); + otherHandImage.shadowColor = handTrackColor; + } + CameraController.trackingHand = boneIndex; + CameraController.setCameraOnBone( + scene, + loadedMesh.fetched.meshes[1], + loadedMesh.skeletons[0], + CameraController.trackingHand ? boneIndex : ParamsManager.boneLock + ); + CameraController.zoom(1.5); + handImage.shadowColor = selectedColor; + }); + } + + // Fetch left and right hand bone indices from EngineController.loadedMesh + const leftHand = EngineController.loadedMesh.fetched.skeletons[0].bones.find(bone => (bone.name === "LeftHand" || bone.name === "leftHand" || bone.name === "hand.L" || bone.name === "Hand.L"))._index; + const rightHand = EngineController.loadedMesh.fetched.skeletons[0].bones.find(bone => (bone.name === "RightHand" || bone.name === "rightHand" || bone.name === "hand.R" || bone.name === "Hand.R"))._index; + createClickableButton('clickableL', BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT, leftHand, "L"); + createClickableButton('clickableR', BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT, rightHand, "R"); + + rootContainer.addControl(trackHandContainer); +} + +function hideShowGui(rootContainer, show) { + if (show == null) { + rootContainer.isVisible = !rootContainer.isVisible; + + // If the GUI is now invisible, reset the camera to the original position + if (!rootContainer.isVisible) { + CameraController.trackingHand = null; + CameraController.setCameraOnBone(scene, loadedMesh.fetched.meshes[1], loadedMesh.skeletons[0], ParamsManager.boneLock); + } + return; + } + + // If the GUI is now invisible, reset the camera to the original position + if (!show) { + CameraController.trackingHand = null; + CameraController.setCameraOnBone(scene, loadedMesh.fetched.meshes[1], loadedMesh.skeletons[0], ParamsManager.boneLock); + } + + rootContainer.isVisible = show; +} diff --git a/media/babylon/retargetAnims.js b/media/babylon/retargetAnims.js new file mode 100644 index 000000000..134f73986 --- /dev/null +++ b/media/babylon/retargetAnims.js @@ -0,0 +1,260 @@ +/* + Function: retargetAnimWithBlendshapes + + Description: + This function takes a target mesh and an animation group and retargets the animation group + to the target mesh. Most importantly, it will also retarget the animation group to the blendshapes + which babylon does not do by default. + + Parameters: + - targetMeshAsset: The mesh to retarget the animation to. + - animGroup: The animation group to retarget. + - cloneName: The name of the cloned animation group. + + Returns: + Void, but the animation group will be retargeted to the target mesh. + And we are able to play this animation group on the target mesh through the scene object. +*/ +function retargetAnimWithBlendshapes(targetMeshAsset, animGroup, cloneName = "anim") { + console.log("Retargeting animation to target mesh..."); + + var morphName = null; + var curMTM = 0; + var morphIndex = 0; + var mtm; + + return animGroup.clone(cloneName, (target) => { + if (!target) { + console.log("No target."); + return null; + } + + // First set all bone targets to the linkedTransformNode + let idx = targetMeshAsset.skeletons[0].getBoneIndexByName(target.name); + var targetBone = targetMeshAsset.skeletons[0].bones[idx]; + if (targetBone) { + return targetBone._linkedTransformNode; + } + + // Iterate over morphManagers if we don't have a new morph target + // Otherwise reset the index + if (morphName !== target.name) { + curMTM = 0; + morphName = target.name; + } + + // If we don't have bones anymore, we can assume we are in the morph target section + morphIndex = getMorphTargetIndex(targetMeshAsset.morphTargetManagers[curMTM], target.name); + + // Sometimes a mesh has extra bits of clothing like glasses, which are not part of the morph targets. + // Because we don't know the order of the morph targets, we need to copy these values to the previous one. + if (morphIndex === -1) { + if (!mtm) { return null; } + else { return mtm; } + } + + mtm = targetMeshAsset.morphTargetManagers[curMTM].getTarget(morphIndex); + curMTM++; + + return mtm; + }); +} + +// Helper function to get the morph target index, since babylon only provides +// morph targets through the index. Which follow GLTF standards but is not useful for us. +function getMorphTargetIndex(morphTargetManager, targetName) { + if (!morphTargetManager) { + console.error("Morph target manager not found."); + return -1; + } + + for (var i = 0; i < morphTargetManager.numTargets; i++) { + if (morphTargetManager.getTarget(i).name === targetName) { + return i; + } + } + + return -1; +} + +/* + The following functions work, but are not used in the current implementation. + They are kept here for future reference. +*/ + +// Helper function that takes a blendshape target name, and returns the value of a json object +// that contains the blendshape targets. +// function getBlendshapeValue(blendshapeTargetName, blendshapeValues) { +// return blendshapeValues[blendshapeTargetName]; +// // for (var i = 0; i < blendshapeValues.length; i++) { +// // if (blendshapeValues[i].name === blendshapeTargetName) { +// // return blendshapeValues[i].value; +// // } +// // } + +// // return null; +// } + +// // Helper function to fetch JSON data from a file +// function fetchJSONData(filePath) { +// return fetch(filePath) +// .then(response => response.json()) +// .then(data => { +// console.log(data); +// return data; +// }) +// .catch(error => { +// console.error('There was a problem with your fetch operation:', error); +// }); +// } + +// function glassesGuyMap() { +// return { +// "morphTarget0": "glassesGuy_mesh_0_23_MorphTarget", +// "morphTarget1": "glassesGuy_mesh_0_25_MorphTarget", +// "morphTarget2": "glassesGuy_mesh_0_27_MorphTarget", +// "morphTarget3": "glassesGuy_mesh_0_29_MorphTarget", +// "morphTarget4": "glassesGuy_mesh_1_24_MorphTarget", +// "morphTarget5": "glassesGuy_mesh_1_26_MorphTarget", +// "morphTarget6": "glassesGuy_mesh_1_28_MorphTarget", +// "morphTarget7": "glassesGuy_mesh_1_30_MorphTarget", +// "morphTarget8": "glassesGuy_mesh_2_0_MorphTarget", +// "morphTarget9": "glassesGuy_mesh_2_1_MorphTarget", +// "morphTarget10": "glassesGuy_mesh_2_2_MorphTarget", +// "morphTarget11": "glassesGuy_mesh_2_3_MorphTarget", +// "morphTarget12": "glassesGuy_mesh_2_4_MorphTarget", +// "morphTarget13": "glassesGuy_mesh_2_5_MorphTarget", +// "morphTarget14": "glassesGuy_mesh_2_6_MorphTarget", +// "morphTarget15": "glassesGuy_mesh_2_7_MorphTarget", +// "morphTarget16": "glassesGuy_mesh_2_8_MorphTarget", +// "morphTarget17": "glassesGuy_mesh_2_9_MorphTarget", +// "morphTarget18": "glassesGuy_mesh_2_10_MorphTarget", +// "morphTarget19": "glassesGuy_mesh_2_11_MorphTarget", +// "morphTarget20": "glassesGuy_mesh_2_12_MorphTarget", +// "morphTarget21": "glassesGuy_mesh_2_13_MorphTarget", +// "morphTarget22": "glassesGuy_mesh_2_14_MorphTarget", +// "morphTarget23": "glassesGuy_mesh_2_15_MorphTarget", +// "morphTarget24": "glassesGuy_mesh_2_16_MorphTarget", +// "morphTarget25": "glassesGuy_mesh_2_17_MorphTarget", +// "morphTarget26": "glassesGuy_mesh_2_18_MorphTarget", +// "morphTarget27": "glassesGuy_mesh_2_19_MorphTarget", +// "morphTarget28": "glassesGuy_mesh_2_20_MorphTarget", +// "morphTarget29": "glassesGuy_mesh_2_21_MorphTarget", +// "morphTarget30": "glassesGuy_mesh_2_22_MorphTarget", +// "morphTarget31": "glassesGuy_mesh_2_23_MorphTarget", +// "morphTarget32": "glassesGuy_mesh_2_24_MorphTarget", +// "morphTarget33": "glassesGuy_mesh_2_25_MorphTarget", +// "morphTarget34": "glassesGuy_mesh_2_26_MorphTarget", +// "morphTarget35": "glassesGuy_mesh_2_27_MorphTarget", +// "morphTarget36": "glassesGuy_mesh_2_28_MorphTarget", +// "morphTarget37": "glassesGuy_mesh_2_29_MorphTarget", +// "morphTarget38": "glassesGuy_mesh_2_30_MorphTarget", +// "morphTarget39": "glassesGuy_mesh_2_31_MorphTarget", +// "morphTarget40": "glassesGuy_mesh_2_32_MorphTarget", +// "morphTarget41": "glassesGuy_mesh_2_33_MorphTarget", +// "morphTarget42": "glassesGuy_mesh_2_34_MorphTarget", +// "morphTarget43": "glassesGuy_mesh_2_35_MorphTarget", +// "morphTarget44": "glassesGuy_mesh_2_36_MorphTarget", +// "morphTarget45": "glassesGuy_mesh_2_37_MorphTarget", +// "morphTarget46": "glassesGuy_mesh_2_38_MorphTarget", +// "morphTarget47": "glassesGuy_mesh_2_39_MorphTarget", +// "morphTarget48": "glassesGuy_mesh_2_40_MorphTarget", +// "morphTarget49": "glassesGuy_mesh_2_41_MorphTarget", +// "morphTarget50": "glassesGuy_mesh_2_42_MorphTarget", +// "morphTarget51": "glassesGuy_mesh_2_43_MorphTarget", +// "morphTarget52": "glassesGuy_mesh_2_44_MorphTarget", +// "morphTarget53": "glassesGuy_mesh_2_45_MorphTarget", +// "morphTarget54": "glassesGuy_mesh_2_46_MorphTarget", +// "morphTarget55": "glassesGuy_mesh_2_47_MorphTarget", +// "morphTarget56": "glassesGuy_mesh_2_48_MorphTarget", +// "morphTarget57": "glassesGuy_mesh_2_50_MorphTarget", +// "morphTarget58": "glassesGuy_mesh_2_51_MorphTarget", +// "morphTarget59": "glassesGuy_mesh_3_9_MorphTarget", +// "morphTarget60": "glassesGuy_mesh_3_10_MorphTarget", +// "morphTarget61": "glassesGuy_mesh_3_11_MorphTarget", +// "morphTarget62": "glassesGuy_mesh_3_34_MorphTarget", +// "morphTarget63": "glassesGuy_mesh_3_49_MorphTarget", +// "morphTarget64": "glassesGuy_mesh_5_0_MorphTarget", +// "morphTarget65": "glassesGuy_mesh_5_1_MorphTarget", +// "morphTarget66": "glassesGuy_mesh_6_0_MorphTarget", +// "morphTarget67": "glassesGuy_mesh_6_1_MorphTarget", +// "morphTarget68": "glassesGuy_mesh_7_0_MorphTarget", +// "morphTarget69": "glassesGuy_mesh_7_1_MorphTarget" +// }; +// } +// function NPMGlassesGuyMap() { +// return { +// "glassesGuy_mesh_0_23_MorphTarget": "morphTarget0", +// "glassesGuy_mesh_0_25_MorphTarget": "morphTarget1", +// "glassesGuy_mesh_0_27_MorphTarget": "morphTarget2", +// "glassesGuy_mesh_0_29_MorphTarget": "morphTarget3", +// "glassesGuy_mesh_1_24_MorphTarget": "morphTarget4", +// "glassesGuy_mesh_1_26_MorphTarget": "morphTarget5", +// "glassesGuy_mesh_1_28_MorphTarget": "morphTarget6", +// "glassesGuy_mesh_1_30_MorphTarget": "morphTarget7", +// "glassesGuy_mesh_2_0_MorphTarget": "morphTarget8", +// "glassesGuy_mesh_2_1_MorphTarget": "morphTarget9", +// "glassesGuy_mesh_2_2_MorphTarget": "morphTarget10", +// "glassesGuy_mesh_2_3_MorphTarget": "morphTarget11", +// "glassesGuy_mesh_2_4_MorphTarget": "morphTarget12", +// "glassesGuy_mesh_2_5_MorphTarget": "morphTarget13", +// "glassesGuy_mesh_2_6_MorphTarget": "morphTarget14", +// "glassesGuy_mesh_2_7_MorphTarget": "morphTarget15", +// "glassesGuy_mesh_2_8_MorphTarget": "morphTarget16", +// "glassesGuy_mesh_2_9_MorphTarget": "morphTarget17", +// "glassesGuy_mesh_2_10_MorphTarget": "morphTarget18", +// "glassesGuy_mesh_2_11_MorphTarget": "morphTarget19", +// "glassesGuy_mesh_2_12_MorphTarget": "morphTarget20", +// "glassesGuy_mesh_2_13_MorphTarget": "morphTarget21", +// "glassesGuy_mesh_2_14_MorphTarget": "morphTarget22", +// "glassesGuy_mesh_2_15_MorphTarget": "morphTarget23", +// "glassesGuy_mesh_2_16_MorphTarget": "morphTarget24", +// "glassesGuy_mesh_2_17_MorphTarget": "morphTarget25", +// "glassesGuy_mesh_2_18_MorphTarget": "morphTarget26", +// "glassesGuy_mesh_2_19_MorphTarget": "morphTarget27", +// "glassesGuy_mesh_2_20_MorphTarget": "morphTarget28", +// "glassesGuy_mesh_2_21_MorphTarget": "morphTarget29", +// "glassesGuy_mesh_2_22_MorphTarget": "morphTarget30", +// "glassesGuy_mesh_2_23_MorphTarget": "morphTarget31", +// "glassesGuy_mesh_2_24_MorphTarget": "morphTarget32", +// "glassesGuy_mesh_2_25_MorphTarget": "morphTarget33", +// "glassesGuy_mesh_2_26_MorphTarget": "morphTarget34", +// "glassesGuy_mesh_2_27_MorphTarget": "morphTarget35", +// "glassesGuy_mesh_2_28_MorphTarget": "morphTarget36", +// "glassesGuy_mesh_2_29_MorphTarget": "morphTarget37", +// "glassesGuy_mesh_2_30_MorphTarget": "morphTarget38", +// "glassesGuy_mesh_2_31_MorphTarget": "morphTarget39", +// "glassesGuy_mesh_2_32_MorphTarget": "morphTarget40", +// "glassesGuy_mesh_2_33_MorphTarget": "morphTarget41", +// "glassesGuy_mesh_2_34_MorphTarget": "morphTarget42", +// "glassesGuy_mesh_2_35_MorphTarget": "morphTarget43", +// "glassesGuy_mesh_2_36_MorphTarget": "morphTarget44", +// "glassesGuy_mesh_2_37_MorphTarget": "morphTarget45", +// "glassesGuy_mesh_2_38_MorphTarget": "morphTarget46", +// "glassesGuy_mesh_2_39_MorphTarget": "morphTarget47", +// "glassesGuy_mesh_2_40_MorphTarget": "morphTarget48", +// "glassesGuy_mesh_2_41_MorphTarget": "morphTarget49", +// "glassesGuy_mesh_2_42_MorphTarget": "morphTarget50", +// "glassesGuy_mesh_2_43_MorphTarget": "morphTarget51", +// "glassesGuy_mesh_2_44_MorphTarget": "morphTarget52", +// "glassesGuy_mesh_2_45_MorphTarget": "morphTarget53", +// "glassesGuy_mesh_2_46_MorphTarget": "morphTarget54", +// "glassesGuy_mesh_2_47_MorphTarget": "morphTarget55", +// "glassesGuy_mesh_2_48_MorphTarget": "morphTarget56", +// "glassesGuy_mesh_2_50_MorphTarget": "morphTarget57", +// "glassesGuy_mesh_2_51_MorphTarget": "morphTarget58", +// "glassesGuy_mesh_3_9_MorphTarget": "morphTarget59", +// "glassesGuy_mesh_3_10_MorphTarget": "morphTarget60", +// "glassesGuy_mesh_3_11_MorphTarget": "morphTarget61", +// "glassesGuy_mesh_3_34_MorphTarget": "morphTarget62", +// "glassesGuy_mesh_3_49_MorphTarget": "morphTarget63", +// "glassesGuy_mesh_5_0_MorphTarget": "morphTarget64", +// "glassesGuy_mesh_5_1_MorphTarget": "morphTarget65", +// "glassesGuy_mesh_6_0_MorphTarget": "morphTarget66", +// "glassesGuy_mesh_6_1_MorphTarget": "morphTarget67", +// "glassesGuy_mesh_7_0_MorphTarget": "morphTarget68", +// "glassesGuy_mesh_7_1_MorphTarget": "morphTarget69" +// }; +// } + +//module.exports = { retargetAnimWithBlendshapes, getMorphTargetIndex }; diff --git a/media/babylon/signBuilder.js b/media/babylon/signBuilder.js new file mode 100644 index 000000000..ff7a261a7 --- /dev/null +++ b/media/babylon/signBuilder.js @@ -0,0 +1,102 @@ +function signBuilder(){ + ParamsManager.animations = []; + removeAnims(scene, loadedMesh); + AnimationSequencer.stop(); + + $("#signBuilder").modal("show") +} + +document.addEventListener('DOMContentLoaded', function () { + var glosArray = []; + + // Simulate loading glosses + async function loadGlos() { + // This should be replaced with your actual data fetching method + var sCArray = await signCollectLoader("Motion Capture", "statusFilter", 1000); //if you want to know thema names, go to signcollect website and ask Gomer for login + var glos = []; // Define the 'glos' variable as an empty array + + for (let i = 0; i < sCArray.length; i++) { + glos[i] = sCArray[i].glos; + } + return glos; // Return the 'glos' array + } + + // Function to handle input for search and display results + window.searchGlos = async function () { + const searchInput = document.getElementById('searchInput').value.toLowerCase(); + const allGlos = await loadGlos(); // Load or filter glos based on input + const glosContainer = document.getElementById('glosContainer'); + glosContainer.innerHTML = ''; // Clear previous results + + allGlos.filter(glos => glos.toLowerCase().includes(searchInput)).forEach(glos => { + console.log(glos) + const label = document.createElement('span'); + label.classList.add('badge', 'bg-secondary', 'me-2'); + label.textContent = glos; + label.onclick = function () { addGlos(glos); }; + glosContainer.appendChild(label); + }); + }; + + // Function to add selected glos to the array + window.addGlos = function (glosName) { + zinArray.push(glosName); + updateGlosDisplay(); + + }; + + // Function to update the display of selected glosses + function updateGlosDisplay() { + const selectedGlosContainer = document.getElementById('selectedGlos'); + selectedGlosContainer.innerHTML = ''; // Clear current glosses + zinArray.forEach(glos => { + const label = document.createElement('span'); + label.classList.add('badge', 'bg-primary', 'me-2'); + label.textContent = glos; + selectedGlosContainer.appendChild(label); + }); + } + + window.cleanSentence = function(){ + zinArray = [] + updateGlosDisplay() + + } + + // Function to handle the final array of glosses + window.handleGlosses = function () { + ParamsManager.animations = zinArray; + + + + $('#signBuilder').modal('hide'); + console.log('Handling glosses:', glosArray); + continueLoop = true; + + if(recordingMethod == "zin") + { + animationSequencing(recording=true, keepPlaying=true, frame="blend"); + + } + else + { + animationSequencing(recording=false, keepPlaying=true, frame="blend"); + + } + }; + + window.toggleRecording = function(){ + //check if the checkbox is checked + if (document.getElementById('recordCheckbox').checked) { + recordingMethod = "zin" + } else { + recordingMethod = "" + } + } + +}); + + + + + diff --git a/media/babylon/signCollectLoader.js b/media/babylon/signCollectLoader.js new file mode 100644 index 000000000..77a22fad0 --- /dev/null +++ b/media/babylon/signCollectLoader.js @@ -0,0 +1,43 @@ +async function signCollectLoader(thema, filter, limit=5) { + try { + console.log("Loading animations from SignCollect, thema:" + thema + " filter:" + filter + " limit: " + limit); + const response = await fetch(`https://leffe.science.uva.nl:8043/fetch_all.php?limit=${limit}&offset=0&handle=${filter}&thema=` + thema); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const text = await response.text(); + const data = JSON.parse(text); + return data; + } catch (error) { + console.error("Failed to load data:", error); + return null; // or you could throw the error further if you want to handle it outside + } +} + +//load animations from signcollect +async function loadSignCollectLabels(local, thema, limit, animations) { + // supress loading from signcollect if we are locally testing + if (local == 1) { + return; + } + + try { + if (!thema) { + thema = "_MOCAP" //when no thema, automatically set to oline, it contains 200 NGT glosses taken in april 2024 👌 + } + var sCArray = await signCollectLoader(thema, "themaFilter", limit); //if you want to know thema names, go to signcollect website and ask Gomer for login + console.log("loadedMesh animations:", animations); + + for (let i = 0; i < sCArray.length; i++) { + // + animations[i] = sCArray[i].glos; + } + console.log("Updated animations:", animations); + + } catch (error) { + // Supress error if we are locally testing + console.error("Error while loading animations:", error); + } + + return animations; +} \ No newline at end of file diff --git a/media/images/finger.svg b/media/images/finger.svg new file mode 100644 index 000000000..a8b0cfa87 --- /dev/null +++ b/media/images/finger.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/media/images/hand.svg b/media/images/hand.svg new file mode 100644 index 000000000..31ab3d3b1 --- /dev/null +++ b/media/images/hand.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/media/images/pause.svg b/media/images/pause.svg new file mode 100644 index 000000000..40a371064 --- /dev/null +++ b/media/images/pause.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/media/images/play.svg b/media/images/play.svg new file mode 100644 index 000000000..06f5c3b2e --- /dev/null +++ b/media/images/play.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/media/images/speed.svg b/media/images/speed.svg new file mode 100644 index 000000000..d1cf40e50 --- /dev/null +++ b/media/images/speed.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/signbank/dictionary/adminviews.py b/signbank/dictionary/adminviews.py index 3767d3071..e398d17ff 100755 --- a/signbank/dictionary/adminviews.py +++ b/signbank/dictionary/adminviews.py @@ -7650,3 +7650,34 @@ def post(self, request, *args, **kwargs): 'selected_datasets': get_selected_datasets_for_user(request.user), 'USE_REGULAR_EXPRESSIONS': use_regular_expressions, 'SHOW_DATASET_INTERFACE_OPTIONS': show_dataset_interface}) + + +class AnimationDetailView(DetailView): + model = GlossAnimation + template_name = 'dictionary/gloss_animation.html' + last_used_dataset = None + fields = [] + + def get_context_data(self, **kwargs): + context = super(AnimationDetailView, self).get_context_data(**kwargs) + + context['SHOW_DATASET_INTERFACE_OPTIONS'] = getattr(settings, 'SHOW_DATASET_INTERFACE_OPTIONS', False) + context['USE_REGULAR_EXPRESSIONS'] = getattr(settings, 'USE_REGULAR_EXPRESSIONS', False) + + selected_datasets = get_selected_datasets_for_user(self.request.user) + context['selected_datasets'] = selected_datasets + dataset_languages = get_dataset_languages(selected_datasets) + context['dataset_languages'] = dataset_languages + context['dataset'] = selected_datasets.first() + + if len(selected_datasets) == 1: + self.last_used_dataset = selected_datasets[0].acronym + elif 'last_used_dataset' in self.request.session.keys(): + self.last_used_dataset = self.request.session['last_used_dataset'] + + context['last_used_dataset'] = self.last_used_dataset + + context['default_dataset_lang'] = dataset_languages.first().language_code_2char if dataset_languages else LANGUAGE_CODE + context['add_animation_form'] = AnimationUploadForObjectForm(self.request.GET, languages=dataset_languages, dataset=self.last_used_dataset) + + return context diff --git a/signbank/dictionary/templates/dictionary/gloss_animation.html b/signbank/dictionary/templates/dictionary/gloss_animation.html new file mode 100644 index 000000000..4a2f2d6b7 --- /dev/null +++ b/signbank/dictionary/templates/dictionary/gloss_animation.html @@ -0,0 +1,463 @@ +{% extends 'baselayout.html' %} +{% load i18n %} +{% load stylesheet %} +{% load bootstrap3 %} +{% load guardian_tags %} +{% block bootstrap3_title %} +{% blocktrans %}Signbank: Gloss Animation{% endblocktrans %} +{% endblock %} + +{% block extrajs %} + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + + + + + + +{% block content %} +{% url 'dictionary:protected_media' '' as protected_media_url %} + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/signbank/dictionary/urls.py b/signbank/dictionary/urls.py index 1bada1dd9..cc01838a9 100755 --- a/signbank/dictionary/urls.py +++ b/signbank/dictionary/urls.py @@ -9,7 +9,7 @@ MorphemeListView, HandshapeDetailView, HandshapeListView, LemmaListView, LemmaCreateView, LemmaDeleteView, LemmaFrequencyView, create_lemma_for_gloss, LemmaUpdateView, SemanticFieldDetailView, SemanticFieldListView, DerivationHistoryDetailView, DerivationHistoryListView, GlossVideosView, KeywordListView, AnnotatedSentenceDetailView, AnnotatedSentenceListView, - AnimationCreateView) + AnimationCreateView, AnimationDetailView) from signbank.dictionary.views import create_citation_image @@ -262,6 +262,7 @@ re_path(r'lemma/update/(?P\d+)$', permission_required('dictionary.change_lemmaidgloss')(LemmaUpdateView.as_view()), name='change_lemma'), re_path(r'^annotatedsentence/(?P\d+)', AnnotatedSentenceDetailView.as_view(), name='admin_annotated_sentence_view'), re_path(r'animation/add/$', permission_required('dictionary.change_glosss')(AnimationCreateView.as_view()), name='create_animation'), + re_path(r'^animation/(?P\d+)', AnimationDetailView.as_view(), name='admin_animation_view'), re_path(r'^keywords/$', KeywordListView.as_view(), name='admin_keyword_list'), From 0f20f8441db687390a83849229ff275e394649ab Mon Sep 17 00:00:00 2001 From: susanodd Date: Wed, 18 Sep 2024 14:29:55 +0200 Subject: [PATCH 06/10] #1298, #1323: Tweaks to gloss animation to show pineapple. --- media/babylon/messageUEServer.js | 8 ++++---- .../templates/dictionary/gloss_animation.html | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/media/babylon/messageUEServer.js b/media/babylon/messageUEServer.js index 053c4a355..b2c470413 100644 --- a/media/babylon/messageUEServer.js +++ b/media/babylon/messageUEServer.js @@ -126,9 +126,9 @@ function sendCommandUEAnim(animURL, skeletonPath = null) { function sendCommandUEAnimRetargetSend(sourceMeshPath, targetMeshPath, animPath, sendPath) { console.log("sendCommandUEAnimRetargetSend", sourceMeshPath, targetMeshPath, animPath, sendPath); // Make sure animPath is a string containing /Game/ImportedAssets/ - if (typeof animPath !== 'string' || !animPath.includes('/Game/ImportedAssets/')) { - return Promise.reject(new Error("animPath must be a string containing /Game/ImportedAssets/")); - } +// if (typeof animPath !== 'string' || !animPath.includes('/Game/ImportedAssets/')) { +// return Promise.reject(new Error("animPath must be a string containing /Game/ImportedAssets/")); +// } return sendMessageUEProxy('rig_retarget_send', sourceMeshPath + "," + targetMeshPath + "," + animPath + "," + sendPath) .then(sourceMeshPathUE => { @@ -154,7 +154,7 @@ function sendCommandUEAnimRetargetSend(sourceMeshPath, targetMeshPath, animPath, * @returns {Promise} - A promise that resolves to true if all steps succeed, or false if any step fails. */ function retargetUE(sourceMeshURL, targetMeshURL, animURL, sendPath, sourceMeshPath = null, targetMeshPath = null, skeletonPath = null) { - const UEDestPath = `/Game/ImportedAssets/`; + const UEDestPath = MeshesAndAnims; const animName = animURL.split('/').pop().split('.')[0].replace('.fbx', ''); // Derive mesh names from the URLs diff --git a/signbank/dictionary/templates/dictionary/gloss_animation.html b/signbank/dictionary/templates/dictionary/gloss_animation.html index 4a2f2d6b7..8015c67d2 100644 --- a/signbank/dictionary/templates/dictionary/gloss_animation.html +++ b/signbank/dictionary/templates/dictionary/gloss_animation.html @@ -94,11 +94,11 @@ var zin = urlParams.get("zin") // IK,BEN,GOMER var limit = urlParams.get("limit") // limit=50, if you want to limit the amount of animations var gltf = urlParams.get("gltf") // gltf=1, if you want to load animations with gltf extension - var local = urlParams.get("local") // local=1, if you want to load animations from local folder + var local = 1; var blending = urlParams.get("blending") // blending=1, if you want to blend animations var debug = urlParams.get("debug") // debug=1, if you want to see debug terminal var lockRot = urlParams.get("lockRot") // debug=1, if you want to see debug terminal - var externalAnim = urlParams.get("externalAnim") // externalAnim="https://leffe.science.uva.nl:8043/gebarenoverleg_media/fbx/appel.glb", if you want to load animations from external source + var externalAnim = urlParams.get("https://leffe.science.uva.nl:8043/gebarenoverleg_media/fbx/appel.glb") var noGui = urlParams.get("noGui") // noGui=1, if you don't want to see the GUI var meshRotation = urlParams.get("meshRotation") // meshRotation=0-360, if you want to rotate the mesh container y-rotation var onboard = urlParams.get("onboard") // onboard=0 or false, if you dont want to play the onboard animation @@ -184,7 +184,7 @@ // Play the onboard animation after 2 seconds if (ParamsManager.onboard) { - setTimeout(onboardAnimation, 2000); + setTimeout(onboardAnimation, 20000); } }); @@ -443,9 +443,10 @@ - - - +
+ +

+
@@ -457,7 +458,8 @@ - - + +
+
{% endblock %} From d954278b0c0d5cd3ebca7af98bbfaa58fc39e516 Mon Sep 17 00:00:00 2001 From: susanodd Date: Wed, 18 Sep 2024 15:02:31 +0200 Subject: [PATCH 07/10] #1298, #1323: Added csrf token to templates. Worked in PyCharm runserver, but not on real server. --- signbank/dictionary/templates/dictionary/add_animation.html | 1 + signbank/dictionary/templates/dictionary/gloss_animation.html | 1 + 2 files changed, 2 insertions(+) diff --git a/signbank/dictionary/templates/dictionary/add_animation.html b/signbank/dictionary/templates/dictionary/add_animation.html index 27944e85f..67e108cfa 100644 --- a/signbank/dictionary/templates/dictionary/add_animation.html +++ b/signbank/dictionary/templates/dictionary/add_animation.html @@ -12,6 +12,7 @@ + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + + + + + + + +{% block content %} +{% url 'dictionary:protected_media' '' as protected_media_url %} + + + +{% if request.GET.warning %} +
{{ request.GET.warning }}
+{% endif %} + + + + + + + + + + + + + +
+

+ +

+
+ + + + + + + + + + + + +
+
+ +{% endblock %} diff --git a/signbank/dictionary/templates/dictionary/gloss_detail.html b/signbank/dictionary/templates/dictionary/gloss_detail.html index fd3daeb04..f83e09152 100755 --- a/signbank/dictionary/templates/dictionary/gloss_detail.html +++ b/signbank/dictionary/templates/dictionary/gloss_detail.html @@ -512,6 +512,9 @@

+ diff --git a/signbank/dictionary/templates/dictionary/gloss_frequency.html b/signbank/dictionary/templates/dictionary/gloss_frequency.html index b4ad93bdb..f52e0b75d 100644 --- a/signbank/dictionary/templates/dictionary/gloss_frequency.html +++ b/signbank/dictionary/templates/dictionary/gloss_frequency.html @@ -1353,6 +1353,9 @@ + diff --git a/signbank/dictionary/templates/dictionary/gloss_revision_history.html b/signbank/dictionary/templates/dictionary/gloss_revision_history.html index 7077da200..a37a003e2 100644 --- a/signbank/dictionary/templates/dictionary/gloss_revision_history.html +++ b/signbank/dictionary/templates/dictionary/gloss_revision_history.html @@ -53,6 +53,9 @@ + diff --git a/signbank/dictionary/templates/dictionary/gloss_videos.html b/signbank/dictionary/templates/dictionary/gloss_videos.html index 28a2d35a8..62574d168 100644 --- a/signbank/dictionary/templates/dictionary/gloss_videos.html +++ b/signbank/dictionary/templates/dictionary/gloss_videos.html @@ -96,6 +96,9 @@ + @@ -250,7 +253,5 @@

{% trans "Citation Form Image" %}

- - {% endblock %} diff --git a/signbank/dictionary/templates/dictionary/related_signs_detail_view.html b/signbank/dictionary/templates/dictionary/related_signs_detail_view.html index c8a5365b4..d2962bf74 100755 --- a/signbank/dictionary/templates/dictionary/related_signs_detail_view.html +++ b/signbank/dictionary/templates/dictionary/related_signs_detail_view.html @@ -80,6 +80,9 @@ + diff --git a/signbank/dictionary/templates/dictionary/word.html b/signbank/dictionary/templates/dictionary/word.html index 2ff33e370..37acd6b29 100755 --- a/signbank/dictionary/templates/dictionary/word.html +++ b/signbank/dictionary/templates/dictionary/word.html @@ -96,6 +96,9 @@ + {% endif %} diff --git a/signbank/dictionary/urls.py b/signbank/dictionary/urls.py index cc01838a9..1458ed8cc 100755 --- a/signbank/dictionary/urls.py +++ b/signbank/dictionary/urls.py @@ -9,7 +9,7 @@ MorphemeListView, HandshapeDetailView, HandshapeListView, LemmaListView, LemmaCreateView, LemmaDeleteView, LemmaFrequencyView, create_lemma_for_gloss, LemmaUpdateView, SemanticFieldDetailView, SemanticFieldListView, DerivationHistoryDetailView, DerivationHistoryListView, GlossVideosView, KeywordListView, AnnotatedSentenceDetailView, AnnotatedSentenceListView, - AnimationCreateView, AnimationDetailView) + AnimationCreateView, AnimationDetailView, GlossAnimationsView) from signbank.dictionary.views import create_citation_image @@ -241,6 +241,7 @@ re_path(r'^handshapes/$', permission_required('dictionary.search_gloss')(HandshapeListView.as_view()), name='admin_handshape_list'), re_path(r'^gloss/(?P\d+)/history', signbank.dictionary.views.gloss_revision_history, name='gloss_revision_history'), re_path(r'^gloss/(?P\d+)/glossvideos', GlossVideosView.as_view(), name='gloss_videos'), + re_path(r'^gloss/(?P\d+)/glossanimations', GlossAnimationsView.as_view(), name='gloss_animations'), re_path(r'^gloss/(?P\d+)', GlossDetailView.as_view(), name='admin_gloss_view'), re_path(r'^gloss_preview/(?P\d+)', GlossDetailView.as_view(), name='admin_gloss_view_colors'), re_path(r'^gloss_frequency/(?P.*)/$', GlossFrequencyView.as_view(), name='admin_frequency_gloss'), From 2ab33db36614a738ef2c1668b2e688f75922d6d9 Mon Sep 17 00:00:00 2001 From: susanodd Date: Fri, 20 Sep 2024 15:17:44 +0200 Subject: [PATCH 09/10] #1298, #1323: Placeholder animation in Animations panel. --- signbank/dictionary/adminviews.py | 7 +- signbank/dictionary/models.py | 10 + .../dictionary/gloss_animations.html | 432 +----------------- .../dictionary/gloss_animations_demo.html | 385 ++++++++++++++++ 4 files changed, 409 insertions(+), 425 deletions(-) create mode 100644 signbank/dictionary/templates/dictionary/gloss_animations_demo.html diff --git a/signbank/dictionary/adminviews.py b/signbank/dictionary/adminviews.py index a698c98ff..c32f0f2be 100755 --- a/signbank/dictionary/adminviews.py +++ b/signbank/dictionary/adminviews.py @@ -7614,18 +7614,15 @@ def post(self, request, *args, **kwargs): selected_datasets = Dataset.objects.filter(pk=request.POST['dataset']) else: selected_datasets = get_selected_datasets_for_user(request.user) - dataset_languages = get_dataset_languages(selected_datasets) + dataset = selected_datasets.first() - dataset = selected_datasets.first() + dataset_languages = get_dataset_languages(selected_datasets) show_dataset_interface = getattr(settings, 'SHOW_DATASET_INTERFACE_OPTIONS', False) use_regular_expressions = getattr(settings, 'USE_REGULAR_EXPRESSIONS', False) form = AnimationUploadForObjectForm(request.POST, languages=dataset_languages, dataset=self.last_used_dataset) - for item, value in request.POST.items(): - print(item, value) - if form.is_valid(): try: animation = form.save() diff --git a/signbank/dictionary/models.py b/signbank/dictionary/models.py index 2692af7d2..c3f39e9aa 100755 --- a/signbank/dictionary/models.py +++ b/signbank/dictionary/models.py @@ -2630,6 +2630,16 @@ def tags(self): from tagging.models import Tag return Tag.objects.get_for_object(self) + def has_animations(self): + from signbank.animation.models import GlossAnimation + animations = GlossAnimation.objects.filter(gloss=self) + return animations.count() + + def get_animations(self): + from signbank.animation.models import GlossAnimation + animations = GlossAnimation.objects.filter(gloss=self) + return animations + def add_animation(self, user, fbxfile): # Preventing circular import from signbank.animation.models import GlossAnimation, get_animation_file_path, GlossAnimationHistory diff --git a/signbank/dictionary/templates/dictionary/gloss_animations.html b/signbank/dictionary/templates/dictionary/gloss_animations.html index 5e7a76d6a..e414655f3 100644 --- a/signbank/dictionary/templates/dictionary/gloss_animations.html +++ b/signbank/dictionary/templates/dictionary/gloss_animations.html @@ -12,6 +12,9 @@ + + + - - - - - - - - - - - - - - - - - {% endblock %} @@ -406,112 +94,16 @@ - - - - - - - - - - -
-

- -

-
- - - - - - - - - - - - +
+ {% for animation in gloss.get_animations %} +
+
+
+
+ {% endfor %} +
+
{% endblock %} diff --git a/signbank/dictionary/templates/dictionary/gloss_animations_demo.html b/signbank/dictionary/templates/dictionary/gloss_animations_demo.html new file mode 100644 index 000000000..ca124b61b --- /dev/null +++ b/signbank/dictionary/templates/dictionary/gloss_animations_demo.html @@ -0,0 +1,385 @@ +{% extends 'baselayout.html' %} +{% load i18n %} +{% load stylesheet %} +{% load bootstrap3 %} +{% load guardian_tags %} +{% block bootstrap3_title %} +{% blocktrans %}Signbank: Gloss Animation{% endblocktrans %} +{% endblock %} + +{% block extrajs %} + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + + + + + + + +{% block content %} +{% url 'dictionary:protected_media' '' as protected_media_url %} + + + +{% if request.GET.warning %} +
{{ request.GET.warning }}
+{% endif %} + + + +
+ {% for animation in gloss.get_animations %} +

+ +

+
+ + +
+ {% endfor %} +
+
+
+ +{% endblock %} From bb2b783fe2a3728a1fa96885bc6e96fe9b158f1f Mon Sep 17 00:00:00 2001 From: susanodd Date: Mon, 23 Sep 2024 14:07:55 +0200 Subject: [PATCH 10/10] #1298, #1323: Renamed file field in model. --- signbank/animation/admin.py | 10 +++++----- signbank/animation/forms.py | 2 +- signbank/animation/migrations/0001_initial.py | 6 +++--- signbank/animation/models.py | 7 +++---- signbank/animation/views.py | 4 ++-- signbank/dictionary/models.py | 12 ++++++------ .../templates/dictionary/add_animation.html | 2 +- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/signbank/animation/admin.py b/signbank/animation/admin.py index 643093814..8ea0b518a 100644 --- a/signbank/animation/admin.py +++ b/signbank/animation/admin.py @@ -12,16 +12,16 @@ class GlossAnimationAdmin(admin.ModelAdmin): - list_display = ['id', 'gloss', 'fbx_file', 'file_timestamp', 'file_size'] + list_display = ['id', 'gloss', 'file', 'file_timestamp', 'file_size'] search_fields = ['^gloss__annotationidglosstranslation__text'] - def fbx_file(self, obj=None): + def file(self, obj=None): # this will display the full path in the list view if obj is None: return "" import os - file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.fbxfile)) + file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.file)) return file_full_path @@ -31,7 +31,7 @@ def file_timestamp(self, obj=None): return "" import os import datetime - file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.fbxfile)) + file_full_path = os.path.join(WRITABLE_FOLDER, str(obj.file)) if os.path.exists(file_full_path): return datetime.datetime.fromtimestamp(os.path.getctime(file_full_path)) else: @@ -43,7 +43,7 @@ def file_size(self, obj=None): return "" else: from pathlib import Path - file_full_path = Path(WRITABLE_FOLDER, str(obj.fbxfile)) + file_full_path = Path(WRITABLE_FOLDER, str(obj.file)) if file_full_path.exists(): size = str(file_full_path.stat().st_size) return size diff --git a/signbank/animation/forms.py b/signbank/animation/forms.py index 00dcfaf44..22c69c8a9 100644 --- a/signbank/animation/forms.py +++ b/signbank/animation/forms.py @@ -19,7 +19,7 @@ class Meta: class AnimationUploadForObjectForm(forms.Form): """Form for animation upload""" - fbxfile = forms.FileField(label=_("Upload FBX File"), + file = forms.FileField(label=_("Upload Animation File"), widget=forms.FileInput(attrs={'accept': 'application/octet-stream'})) gloss_id = forms.CharField(widget=forms.HiddenInput) object_type = forms.CharField(widget=forms.HiddenInput) diff --git a/signbank/animation/migrations/0001_initial.py b/signbank/animation/migrations/0001_initial.py index b80014511..b6578b88e 100644 --- a/signbank/animation/migrations/0001_initial.py +++ b/signbank/animation/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-12 14:27 +# Generated by Django 4.2.15 on 2024-09-23 11:21 from django.conf import settings from django.db import migrations, models @@ -11,8 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('dictionary', '0086_alter_dataset_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -35,7 +35,7 @@ class Migration(migrations.Migration): name='GlossAnimation', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('fbxfile', models.FileField(storage=signbank.animation.models.AnimationStorage(), upload_to='', validators=[signbank.animation.models.validate_file_extension], verbose_name='FBX file')), + ('file', models.FileField(storage=signbank.animation.models.AnimationStorage(), upload_to='', validators=[signbank.animation.models.validate_file_extension], verbose_name='Animation file')), ('gloss', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dictionary.gloss')), ], ), diff --git a/signbank/animation/models.py b/signbank/animation/models.py index b2fba94b9..79dae284f 100644 --- a/signbank/animation/models.py +++ b/signbank/animation/models.py @@ -17,7 +17,6 @@ from datetime import datetime from signbank.dictionary.models import * -from pyfbx import Fbx if sys.argv[0] == 'mod_wsgi': @@ -89,7 +88,7 @@ def get_animation_file_path(instance, filename): class GlossAnimation(models.Model): """An animation that represents a particular idgloss""" - fbxfile = models.FileField("FBX file", storage=storage, + file = models.FileField("Animation file", storage=storage, validators=[validate_file_extension]) gloss = models.ForeignKey(Gloss, on_delete=models.CASCADE) @@ -107,7 +106,7 @@ def save(self, *args, **kwargs): def get_absolute_url(self): - return self.fbxfile.name + return self.file.name class GlossAnimationHistory(models.Model): @@ -116,7 +115,7 @@ class GlossAnimationHistory(models.Model): action = models.CharField("Animation History Action", max_length=6, choices=ACTION_CHOICES, default='watch') # When was this action done? datestamp = models.DateTimeField("Date and time of action", auto_now_add=True) - # See 'fbxfile' in animation.views.addanimation + # See 'file' in animation.views.addanimation uploadfile = models.TextField("User upload path", default='(not specified)') # See 'goal_location' in addanimation goal_location = models.TextField("Full target path", default='(not specified)') diff --git a/signbank/animation/views.py b/signbank/animation/views.py index c3bc819b5..9d392571b 100644 --- a/signbank/animation/views.py +++ b/signbank/animation/views.py @@ -32,13 +32,13 @@ def addanimation(request): # Unpack the form gloss_id = form.cleaned_data['gloss_id'] object_type = form.cleaned_data['object_type'] - fbxfile = form.cleaned_data['fbxfile'] + file = form.cleaned_data['file'] redirect_url = form.cleaned_data['redirect'] if object_type == 'gloss_animation': gloss = Gloss.objects.filter(id=int(gloss_id)).first() if not gloss: redirect(redirect_url) - gloss.add_animation(request.user, fbxfile) + gloss.add_animation(request.user, file) return redirect(redirect_url) diff --git a/signbank/dictionary/models.py b/signbank/dictionary/models.py index c3f39e9aa..a7fd012fa 100755 --- a/signbank/dictionary/models.py +++ b/signbank/dictionary/models.py @@ -2640,22 +2640,22 @@ def get_animations(self): animations = GlossAnimation.objects.filter(gloss=self) return animations - def add_animation(self, user, fbxfile): + def add_animation(self, user, file): # Preventing circular import from signbank.animation.models import GlossAnimation, get_animation_file_path, GlossAnimationHistory # Create a new GlossAnimation object - if isinstance(fbxfile, File) or fbxfile.content_type == 'django.core.files.uploadedfile.InMemoryUploadedFile': + if isinstance(file, File) or file.content_type == 'django.core.files.uploadedfile.InMemoryUploadedFile': animation = GlossAnimation(gloss=self, upload_to=get_animation_file_path) # Create a GlossAnimationHistory object - relative_path = get_animation_file_path(animation, str(fbxfile)) + relative_path = get_animation_file_path(animation, str(file)) animation_file_full_path = os.path.join(WRITABLE_FOLDER, relative_path) glossanimationhistory = GlossAnimationHistory(action="upload", gloss=self, actor=user, - uploadfile=fbxfile, goal_location=animation_file_full_path) + uploadfile=file, goal_location=animation_file_full_path) - # Save the new fbx file in the animation object - animation.fbxfile.save(relative_path, fbxfile) + # Save the new file in the animation object + animation.file.save(relative_path, file) glossanimationhistory.save() else: diff --git a/signbank/dictionary/templates/dictionary/add_animation.html b/signbank/dictionary/templates/dictionary/add_animation.html index 0da0e3f56..986d996a3 100644 --- a/signbank/dictionary/templates/dictionary/add_animation.html +++ b/signbank/dictionary/templates/dictionary/add_animation.html @@ -89,7 +89,7 @@

{% trans "Upload New Animation" %}

- {{add_animation_form.fbxfile}} + {{add_animation_form.file}}