Browse Source

Premier commit de la réécriture de Scribe

v0.2
glorf 5 months ago
parent
commit
d044436210
  1. 19
      .gitignore
  2. 140
      README.md
  3. 63
      install-tr.sh
  4. 4
      maj_yt-dlp.sh
  5. 10
      nginx/scribe.conf
  6. 9
      nginx/uwsgi.conf
  7. 12
      requirements.txt
  8. 7
      run
  9. 12
      scribe.ini
  10. 335
      scribe.py
  11. 151
      scribe/__init__.py
  12. 132
      scribe/lib.py
  13. 0
      scribe/static/april-logo.png
  14. 0
      scribe/static/cemea-logo.png
  15. 0
      scribe/static/commonvoice-logo.png
  16. 0
      scribe/static/scribe.png
  17. 1
      scribe/static/vendor/bulma-0.9.3.min.css
  18. 34
      scribe/static/vendor/fontawesome-free-5.15.4-web/LICENSE.txt
  19. 3
      scribe/static/vendor/fontawesome-free-5.15.4-web/attribution.js
  20. 4616
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/all.css
  21. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/all.min.css
  22. 15
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/brands.css
  23. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/brands.min.css
  24. 4582
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/fontawesome.css
  25. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/fontawesome.min.css
  26. 15
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/regular.css
  27. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/regular.min.css
  28. 16
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/solid.css
  29. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/solid.min.css
  30. 371
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/svg-with-js.css
  31. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/svg-with-js.min.css
  32. 2172
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/v4-shims.css
  33. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/css/v4-shims.min.css
  34. 4466
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/all.js
  35. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/all.min.js
  36. 585
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/brands.js
  37. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/brands.min.js
  38. 998
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/conflict-detection.js
  39. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/conflict-detection.min.js
  40. 2483
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/fontawesome.js
  41. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/fontawesome.min.js
  42. 280
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/regular.js
  43. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/regular.min.js
  44. 1130
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/solid.js
  45. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/solid.min.js
  46. 68
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/v4-shims.js
  47. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/js/v4-shims.min.js
  48. 19
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_animated.less
  49. 16
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_bordered-pulled.less
  50. 12
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_core.less
  51. 6
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_fixed-width.less
  52. 1461
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_icons.less
  53. 27
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_larger.less
  54. 18
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_list.less
  55. 56
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_mixins.less
  56. 24
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_rotated-flipped.less
  57. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_screen-reader.less
  58. 2066
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_shims.less
  59. 22
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_stacked.less
  60. 1473
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/_variables.less
  61. 23
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/brands.less
  62. 16
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/fontawesome.less
  63. 23
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/regular.less
  64. 24
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/solid.less
  65. 6
      scribe/static/vendor/fontawesome-free-5.15.4-web/less/v4-shims.less
  66. 2572
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/categories.yml
  67. 58522
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/icons.json
  68. 21780
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/icons.yml
  69. 2317
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/shims.json
  70. 298
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/shims.yml
  71. 744
      scribe/static/vendor/fontawesome-free-5.15.4-web/metadata/sponsors.yml
  72. 20
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_animated.scss
  73. 20
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_bordered-pulled.scss
  74. 21
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_core.scss
  75. 6
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_fixed-width.scss
  76. 1461
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_icons.scss
  77. 23
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_larger.scss
  78. 18
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_list.scss
  79. 56
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_mixins.scss
  80. 24
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_rotated-flipped.scss
  81. 5
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_screen-reader.scss
  82. 2066
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_shims.scss
  83. 31
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_stacked.scss
  84. 1478
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/_variables.scss
  85. 23
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/brands.scss
  86. 16
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/fontawesome.scss
  87. 23
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/regular.scss
  88. 24
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/solid.scss
  89. 6
      scribe/static/vendor/fontawesome-free-5.15.4-web/scss/v4-shims.scss
  90. 1378
      scribe/static/vendor/fontawesome-free-5.15.4-web/sprites/brands.svg
  91. 463
      scribe/static/vendor/fontawesome-free-5.15.4-web/sprites/regular.svg
  92. 3013
      scribe/static/vendor/fontawesome-free-5.15.4-web/sprites/solid.svg
  93. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/500px.svg
  94. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/accessible-icon.svg
  95. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/accusoft.svg
  96. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/acquisitions-incorporated.svg
  97. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/adn.svg
  98. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/adversal.svg
  99. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/affiliatetheme.svg
  100. 1
      scribe/static/vendor/fontawesome-free-5.15.4-web/svgs/brands/airbnb.svg
  101. Some files were not shown because too many files have changed in this diff Show More

19
.gitignore vendored

@ -5,3 +5,22 @@ tmp
scribe.sock
scribeprojectenv
__pycache__/
.env
venv/
*.pyc
__pycache__/
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
*.swp

140
README.md

@ -1,74 +1,124 @@
# Scribe
Dépot de code pour interface web de projet de transcription audio basé sur le projet (Transcription)[https://forge.chapril.org/tykayn/transcription]
Dépôt de code pour interface web de transcription audio basé sur le projet [https://github.com/alphacep/vosk-server](vosk-server).
Les fichiers audio/vidéo envoyés, ainsi que leur transcription, sont supprimés dès la fin du traitement (réception du deuxième mail).
## Pré-requis
Ce script s'installe sur une distribution Linux avec pip3 > 19 comme Ubuntu 20.04 (pas Debian 10).
* Un système pouvant faire tourner docker
* ffmpeg installé
* Sous certaines debian, avoir installé python-is-python3
## vosk-server
## Installation dans /srvscribe
Pour cela, rien de plus simple :
Par défaut le projet est installé dans /srv/scribe (mais modifiable)
```
cd /srv/
git clone https://code.cemea.org/francois.audirac/scribe.git
cd scribe
chmod +x install-tr.sh
./install-tr.sh
docker run -d -p 2700:2700 alphacep/kaldi-fr:latest
docker run -d -p 2701:2701 alphacep/kaldi-en:latest
```
Et répondre 'o' aux questions (ou regarder le contenu du script pour suivre les étapes)
Il faut personnaliser le .env avec les bonnes valeurs et redémarrer le service scribe :
Il est possible de ne faire tourner qu'un seul modèle, ou plus de 2. Attention cependant, chaque modèle consomme environ 2 GO de RAM, prévoyez la machine en conséquence ! Scribe communiquant avec le vosk-server via des websockets, il est tout à fait possible d'héberger les modèles sur un (ou plusieurs) autres serveurs
### Youtube-DL
Actuellement (7 mai 2022), les téléchargements sont extrêmement lent avec la dernière release de youtube-dl (2021.12.17) - bridés à environ 50ko/s. Voir [https://github.com/alphacep/vosk-server](l'issue github) pour plus d'informations.
Il va donc falloir créer le .whl à la main, et on l'installera à la main dans le virtualenv. Pour cela c'est heureusement assez simple :
```
systemctl restart scribe
cd /tmp
git clone https://github.com/ytdl-org/youtube-dl
cd youtube-dl
python setup.py bdist_wheel
```
## Utilisation du service via le navigateur web
Vous trouverez ensuite le .whl dans `dist/`.
1. se rendre sur la page du service https://scribe.domaine.ext
2. Cliquer pour sélectionner son fichier ou coller l'URL d'une vidéo Youtube / Peertube...
3. Indiquer une adresse e-mail (obligatoire)
4. Envoyer et patienter (environ le temps de la durée de l'audio ou vidéo)
5. Consulter votre messagerie et cliquer pour télécharger le fichier .Zip contenant les fichiers textes transcrits par le Scribe.
## Installation
## TODO
- voir comment optimiser l'utilisation' du proc avec le multithreading !
- stocker les logs en cas d'erreur (ou non)
- intégrer du wsgi pour multi-traitement
- veiller à ne pas mettre à genoux le serveur avec un message d'alerte pour temporiser si le proc est très sollicité :-(
- surveiller l'espace disque utilisé et bloquer le service si saturé
- Nettoyer les fichiers uploadés
Rien d'inhabituel :
```
git clone https://code.cemea.org/mallettecemea/scribe
cd scribe
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
pip install --force-reinstall /tmp/youtube-dl/dist/youtube_dl-2021.12.17-py2.py3-none-any.whl
```
## Contenu du projet
## Configuration
Copiez le fichier `settings.toml.example` en `settings.toml` puis modifiez le à votre guise
### Serveur mail
### .env
Si vous ne souhaitez pas utiliser les mails (par exemple, pour un usage local), il suffit de régler `use_mail = false` dans votre `settings.toml`. Dans ce cas, les .zip générés seront exportés vers `instance/{random_token}.zip`.
Fichier d'environnement et de variables génériques à personnaliser (URL, Titre du site...)
Copier .env.example en .env et compléter les champs utiles
Il n'est possible de se connecter qu'à un serveur mail supportant SSL/TLS (pas de STARTTLS).
### fichier run
## Lancement
Il sert de lanceur en chargeant le .env
Le projet peut être lancé à la main avec :
### Mode dev
En mode dev avec flask :
```
./run ./scribe.py
export FLASK_APP=scribe
export FLASK_ENV=development
flask run
```
Ensuite, vous pourrez visiter `http://localhost:5000` pour vérifier que tout fonctionne
### Déploiement réel (en production)
`flask run` n'étant pas adapté à un vrai déploiement, il vaut mieux utiliser gunicorn + nginx. Dans ce cas, il faudra installer gunicorn :
```
pip install gunicorn
```
Vous trouverez ensuite un fichier d'exemple pour lancer gunicorn via systemd dans `systemd`, et un fichier exemple de reverse-proxy nginx dans `nginx`.
## Utilisation
* se rendre sur la page du service https://scribe.domaine.ext
* Cliquer pour sélectionner son fichier ou coller l'URL d'une vidéo Youtube / Peertube...
* Indiquer une adresse e-mail (obligatoire)
* Envoyer et patienter (environ le temps de la durée de l'audio ou vidéo)
* Consulter votre messagerie et cliquer pour télécharger le fichier .Zip contenant les fichiers textes transcrits par le Scribe.
## Contenu du projet
### settings.toml
Contient la configuration du site. Copier `settings.toml.example` en `settings.toml` et compléter les champs utile
### nginx & systemd
Contient des exemples pour faire tourner scribe avec gunicorn via systemd + nginx
### scribe/
#### __init__.py
L'application Flask à proprement parler, avec ses routes, est créé dans ce fichier.
#### types.py
Contient les informations de type spécifique à ce projet
#### utils.py
### fichier scribe.py
Contient des fonctions utilitaires "générique" pas forcément spécifique à Scribe
Script python unique et principal du projet Scribe
#### lib.py
### dossier transcribe
Il contient des scripts spécifiques (référencés dans le .env) qui doivent être copiés dans le dossier /srv/transcription
Contient les fonctions spécifique à Scribe (communication avec vosk, traitement des résultats ...)
### dossier systemd
Il contient les unités systemd
#### templates/
### dossier nginx
Il contient la conf nginx scribe.conf
Contient les modèles de pages affichés et les mails envoyés
### dossier template et statics
Ils contiennent les modèles de pages affichés ou de mails envoyés.
#### static/
### dossier tmp
Pour l'instant, c'est le dossier de réception des fichiers transcrits et compressés en .zip
Contient les fichiers CSS + les images nécessaire.

63
install-tr.sh

@ -1,63 +0,0 @@
#!/usr/bin/env bash
echo " Ce script install le projet transcription ainsi que les modeles dans /srv/transcription"
echo "Il installe aussi les services liés au projet scribe, installé normalement dans le dossier /srv/scribe"
echo "Vérification Version"
version=`lsb_release -a`
apt-get -y update
apt-get -y dist-upgrade
useradd scribe
echo " Installation des pré-requis"
apt-cache show python3-pip|grep Version
echo "Version 19 minimun ? (o/Ctrl+c)"
read bidon
echo "Lancement de l'installation du Projet transcription. Installer ? (o/Ctrl+c)"
read bidon
apt install jq python3-pip git ffmpeg unzip nginx zip curl
echo "Installation de yt-dlp pour téléchargement de Vidéos"
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
chmod a+rx /usr/local/bin/yt-dlp
cd /srv/
git clone https://forge.chapril.org/tykayn/transcription.git
cd transcription/
echo "Lancement de l'installation de langue fr"
make
chmod +x /srv/scribe/transcription/*.sh
echo "Installation de langue anglais"
wget https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip
unzip vosk-model-en-us-daanzu-20200905.zip
mv vosk-model-en-us-daanzu-20200905 modeles/en
echo "Lancement de la démo ? (o/n)"
read bidon
cd /srv/scribe
echo "Installation des scripts custom"
cp transcribe/* /srv/transcription/
echo "Installation de systemd + nginx ? (o/n)"
cp systemd/scribe.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable scribe.service
systemctl status scribe.service
cp nginx/scribe.conf /etc/nginx/sites-available/
ln -s /etc/nginx/sites-available/scribe.conf /etc/nginx/sites-enable/
nginx -t
systemctl restart nginx
systemctl status nginx.service
echo "Copie du fichier .env : A PERSONNALISER"
cp .env.example .env
echo "Tester avec ./run ./scribe.py"
echo "Puis relancer le service : systemd restart scribe.service"
chown scribe:scribe /srv/scribe /srv/transcription

4
maj_yt-dlp.sh

@ -1,4 +0,0 @@
#!/usr/bin/env bash
wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp
chmod a+rx /usr/local/bin/yt-dlp

10
nginx/scribe.conf

@ -1,13 +1,15 @@
upstream scribe {
server unix:/srv/scribe/scribe.sock;
}
server {
listen 80;
server_name scribe.domain.ext;
root /srv/scribe/;
server_name scribe.local;
root /srv/scribe;
location / {
proxy_pass http://127.0.0.1:5000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_pass http://scribe;
}
}

9
nginx/uwsgi.conf

@ -1,9 +0,0 @@
server {
listen 80;
server_name scribe.cemea.org;
location / {
include uwsgi_params;
uwsgi_pass unix:/home/scribe/scribeproject/scribe.sock;
}
}

12
requirements.txt

@ -0,0 +1,12 @@
click==8.1.3
ffmpeg-python==0.2.0
Flask==2.1.2
future==0.18.2
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.1
srt==3.5.2
toml==0.10.2
websockets==10.3
Werkzeug==2.1.2
youtube-dl==2021.12.17

7
run

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -o allexport
. .env
export ENV_SETTINGS="$PWD/.env"
exec "$1"

12
scribe.ini

@ -1,12 +0,0 @@
[uwsgi]
module = wsgi:app
master = true
processes = 5
socket = scribe.sock
chmod-socket = 666
vacuum = true
die-on-term = true

335
scribe.py

@ -1,335 +0,0 @@
#!/usr/bin/env python3
from flask import Flask, request, flash, redirect, render_template, url_for, g, send_from_directory
import requests, smtplib, ssl, sys, pdb, os, re
from subprocess import Popen
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.header import Header
from werkzeug.utils import secure_filename
from secrets import token_hex
from datetime import datetime
from time import sleep
from multiprocessing import Process
ALLOWED_EXTENSIONS = set(
['wav','mp3','ogg','ac3','mka','m4a','flac',
'wma','opus','mp4','webm','avi','mkv','m4v',
'mov','ogv','wmv','mpg','mpeg','flv']
)
def sendmail(email, file_id, lang, filename, url, resultat, template):
# Envoi un e-mail avec les infos pour script à télécharger
now = datetime.now()
maildateenvoi = now.strftime('%d-%m-%Y %H:%M:%S')
contenutxt = 'Bonjour,"\r\n\"\r\n'
if resultat == 0:
contenutxt = 'Bravo, félicitations !'
else:
contenutxt = "**Attention, il y a eu une erreur**.\r\n\
Merci d'informer l'administrateur de ce site du problème rencontré : \r\n"\
+app.config['MAIL_ADMIN']\
+"Vous pouvez lui transférer ce mail pour plus de détails."
# txtmessage += "Content-Type: text/plain; charset=UTF-8"
contenutxt += "\r\n\r\n" + render_template(template+'.txt',
file_id = file_id, lang = lang,
filename = filename, url = url,
message = contenutxt, resultat = resultat,
site_url = app.config['SITE_URL']
)
message = MIMEText(contenutxt.encode('utf-8'), 'plain', 'utf-8')
message["From"] = app.config['MAILFROM']
message["To"] = email
message.set_charset("utf-8")
# Création du titre du mail
titre = app.config['SITE_TITLE']
if template == 'prepa':
titre += ' : Lancement'
if template == 'fin':
titre += ' : FIN'
message["Subject"] = Header(titre,'utf-8')
# *******************
# GROS DEBUG envoi Mail forcé à commenter pour production
# *******************
bcopies = [app.config['MAILADMIN']]
message["Bcc"]= ", ".join([app.config['MAILADMIN']])
# Envoi du mail
server = smtplib.SMTP_SSL(host=app.config['SMTP_HOST'],port=app.config['SMTP_PORT'])
server.login(app.config['SMTP_USER'], app.config['SMTP_PASSWORD'])
# Envoi du message
server.sendmail(message["From"], [message["To"]]+bcopies, message.as_string())
server.quit()
reponse = "Mail envoyé le "+str(maildateenvoi)+" à "+str(message["To"])+"\r\n"+str(contenutxt)+"\r\n"
print(reponse)
return 0
def tache_en_fond(email, file_id, lang, filename, address, srt, resultat, template):
# Taches en fond : * téléchargement + conversion Mono + transcription
if address != '':
# On gère une adresse
resultat_transcrit = transcriturl(address,file_id,lang,srt)
print("le RESULTAT :", resultat_transcrit)
else:
# On gère un fichier Fichier
resultat_transcrit = transcrit(filename, file_id,lang,srt)
# DEBUG resultat_transcrit = 0
print("le RESULTAT :", resultat_transcrit)
if resultat_transcrit == 0:
sendmail(email, file_id, lang, filename, address, resultat_transcrit,'fin')
else:
sendmail(email, file_id, lang, filename, address, resultat_transcrit,'fin')
def allowed_file(filename):
mysplit_tup = os.path.splitext(filename)
# DEBUG print("split",mysplit_tup)
myfilename_ext = mysplit_tup[1]
# DEBUG print("mysplit_up-1",mysplit_tup[1])
return (myfilename_ext.lower()[1:] in ALLOWED_EXTENSIONS)
def upload_form():
loadsrv = float(open("/proc/loadavg").readline().split(" ")[:3][0])
loadaverage = ""
if loadsrv > (2*app.config['NB_CORES']):
loadaverage = "veryhigh"
elif loadsrv > app.config['NB_CORES']:
loadaverage = "high"
return render_template('index.html',
ALLOWED_EXTENSIONS = ALLOWED_EXTENSIONS,
title = app.config['SITE_TITLE'],
loadaverage = loadaverage)
def transcriturl(address, file_id,lang,srt):
# transcrit une URL fournie en paramètre grâce à Youtube-dl
if os.path.isfile(app.config['TRANSC_FOLDER']+"/"+app.config['TRANSC_URL']):
os.chdir(app.config['TRANSC_FOLDER'])
# Le script télécharg et convertit le fichier en un fichier MP3
resultat = Popen([
"nice -n 19 /bin/bash "+app.config['TRANSC_URL']+" "
+address+" '"
+file_id+"'"],
shell = True)
resultat.wait()
# Le script convertit le fichier MP3 en Wav mono
resultat = Popen([
"nice -n 19 /bin/bash "+app.config['TRANSC_WAV']+" "
+app.config['TRANSC_INPUT']+"/"+file_id+".mp3"],
shell = True)
resultat.wait()
# On transcrit le fichier wav en Texte
load_average_wait(app.config['NB_CORES'])
resultat=Popen([
"nice -n 19 /bin/bash "+app.config['TRANSC_EXEC']+" "
+app.config['TRANSC_INPUT']+"/"+app.config['TRANSC_MONO_FOLDER']+"/"+file_id+".wav"+" "
+lang+" "
+srt],
shell=True)
resultat.wait()
if resultat.returncode == 0:
if app.config['FLASK_ENV'] != 'development':
# On efface le fichier wav et le fichier mp3
print("On efface le wav")
os.remove(app.config['TRANSC_INPUT']+"/"+app.config['TRANSC_MONO_FOLDER']+"/"+file_id+".wav")
print("On efface le MP3")
os.remove(app.config['TRANSC_INPUT']+"/"+file_id+".mp3")
print("On zipppe tout")
os.chdir(APP_FOLDER)
# on zippe le résultat dans UPLOAD_FOLDER
resultat = Popen([
"zip -j "+app.config['UPLOAD_FOLDER']+"/"+file_id+" "
+app.config['TRANSC_FOLDER']+"/"+app.config['TRANSC_OUTPUT']+"/"+file_id+"/*"],
shell = True)
resultat.wait()
print("Résultat ZIP : ",resultat.returncode)
# TODO : on devrait effacer le fichier d'origine et le .wav converti
return resultat.returncode
else:
return 1
def transcrit(filename, file_id,lang,srt):
print("Résultat de ",filename,"transcrit et file_id", file_id)
if os.path.isfile(app.config['UPLOAD_FOLDER']+"/"+filename):
# recherche de l'extension
split_tup = os.path.splitext(app.config['UPLOAD_FOLDER']+"/"+filename)
filename_ext = split_tup[1]
os.rename(app.config['UPLOAD_FOLDER']+"/"+filename,
app.config['TRANSC_FOLDER']+"/"+app.config['TRANSC_INPUT']+"/"+file_id+filename_ext)
if os.path.isfile(app.config['TRANSC_FOLDER']+"/"+app.config['TRANSC_EXEC']):
os.chdir(app.config['TRANSC_FOLDER'])
# On convertit ce fichier en Wav-mono dans converted_to_wav
print("L'extension de ce fichier est : ",filename_ext.lower()[1:])
resultat = Popen(["nice -n 19 bash "+app.config['TRANSC_WAV']+" "
+app.config['TRANSC_INPUT']+"/"+file_id+filename_ext],
shell = True)
resultat.wait()
print("Résultat Conversion WAV Mono : ",resultat.returncode)
if resultat.returncode == 0:
print("Lancement du script : ","./"+app.config['TRANSC_EXEC']," ",
app.config['TRANSC_INPUT'],"/",file_id,".wav "
+lang+" "
+srt)
# Lancement de Transcription du Wav mono en texte
load_average_wait(app.config['NB_CORES'])
resultat = Popen(["nice -n 19 bash "+app.config['TRANSC_EXEC']+" "\
+app.config['TRANSC_INPUT']+"/"+app.config['TRANSC_MONO_FOLDER']+"/"+file_id+".wav "
+lang+" "
+srt],
shell=True)
resultat.wait()
else:
print("Erreur lors de la conversion WAV")
return resultat.returncode
print("Résultat TRANSC : ",resultat.returncode)
if resultat.returncode == 0:
if app.config['FLASK_ENV'] != 'development':
# On efface le fichier d'origine et le fichier wav
print("On efface le fichier original")
os.remove(app.config['TRANSC_INPUT']+"/"+file_id+filename_ext)
print("On efface le wav")
os.remove(app.config['TRANSC_INPUT']+"/"+app.config['TRANSC_MONO_FOLDER']+"/"+file_id+".wav")
print("On zipppe tout")
# on zippe le résultat dans UPLOAD_FOLDER
os.chdir(APP_FOLDER)
resultat = Popen(["zip -j "+app.config['UPLOAD_FOLDER']+"/"+file_id+" "\
+app.config['TRANSC_FOLDER']+"/"+app.config['TRANSC_OUTPUT']+"/"+file_id+"/*"],
shell = True)
resultat.wait()
# print("Résultat ZIP : ",resultat.returncode)
# TODO : on devrait effacer le fichier d'origine et le .wav converti
return resultat.returncode
else:
print("Script non lancé")
print("dossier : ",os.getcwd())
print(os.system("ls -l"))
return 1
else:
print("pas de fichier ",app.config['UPLOAD_FOLDER']+"/"+filename)
return 1
app = Flask(__name__)
@app.route("/downloadfile/<file_id>", methods = ['GET'])
def download_file(file_id):
return send_from_directory(app.config['UPLOAD_FOLDER'], file_id+".zip", as_attachment=True)
# return render_template('download.html',value=file_id,chemin=)
@app.route('/dest', methods=['POST'])
def upload_file():
global address, filename
if request.method == 'POST':
# check if the post request has the file part
# pdb.set_trace()
filename = ''
address = ''
lang = request.form.get('lang')
email = request.form.get('email')
srt = request.form.get('srt')
if srt != "true":
srt = "false"
else:
srt = "true"
if lang != 'fr':
lang = 'en'
if 'file' not in request.files:
print('No file part')
if 'url' not in request:
return redirect(request.url)
# On génère un Token aleatoire pour le nouveau nom de fichier de destination
file_id = token_hex(16)
address = request.form.get('url')
if address is not None and address != '':
filename = ''
# Cas ou une URL est saisie
print("Adresse : ",address)
else:
address = ''
# Cas où un fichier est envoyé
file = request.files['file']
# pdb.set_trace()
# if file.filename == '':
# print('No file selected for uploading')
# return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
# Le fichier envoyé est sauvegardé dans le dosier UPLOAD_FOLDER (tmp)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
# On lance la transcription et on récupère le résultat
# DEBUG
else:
print("Ce fichier n'est PAS accepté : ",filename)
print('Les types supportés sont : ',ALLOWED_EXTENSIONS)
return redirect('/')
# Envoi du mail avant traitement
sendmail(email, file_id, lang, filename, address, 0, 'prepa')
p = Process(target=tache_en_fond, args=(email, file_id, lang, filename, address, srt, 0, 'prepa'),daemon=True)
# p = Process(target=tache_en_fond, args=(email, file_id, lang, filename, address, 0, 'prepa'))
p.start()
if address == "":
fichier_ou_url = filename
else:
fichier_ou_url = address
return render_template("wait.html",
title = app.config['SITE_TITLE'],
url_site = app.config['SITE_URL'],
filename = fichier_ou_url,
file_id = file_id,
message = "Tout est ok !")
else:
return render_template("wait.html",
title = app.config['SITE_TITLE'],
url_site = app.config['SITE_URL'],
filename = fichier_ou_url,
file_id = file_id,
message = "Il y a eu une erreur !")
@app.route('/go/<file_id>', methods=['GET'])
def message_info(file_id):
return render_template("result.html",
title = app.config['SITE_TITLE'],
filename = filename,
address = address,
file_id = file_id,
message = "Bravo, la transcription a été lancée. Consultez votre messagerie pour obtenir le résultat.")
@app.route('/pourquoi')
def message_pourquoi():
return render_template("pourquoi.html",
title = app.config['SITE_TITLE'])
def load_average_wait(treshold=1):
# If server is above the treshold value, wait for lower load before continue
while float(open("/proc/loadavg").readline().split(" ")[:3][0])> treshold:
print("Charge élevée, on patiente 30 sec...")
sleep(30)
if __name__ == '__main__':
APP_FOLDER = os.path.dirname(os.path.realpath(__file__))
nb_coeurs = os.cpu_count()
app.config.update(dict(
APP_ADDRESS='localhost',
APP_PORT='5000',
UPLOAD_FOLDER='tmp',
NB_CORES = nb_coeurs
))
# Chargement paramètres depuis .env - définir dans .run
app.config.from_envvar('ENV_SETTINGS', silent=False)
app.secret_key = app.config['SECRET']
# Chargement variables depuis .toml
if not os.path.isdir(app.config['UPLOAD_FOLDER']):
print(app.config['UPLOAD_FOLDER']+" absent : création !")
os.mkdir(app.config['UPLOAD_FOLDER'])
app.add_url_rule("/", "index", upload_form, methods = ['GET', 'POST'])
app.run(host=app.config['APP_ADDRESS'], port=app.config['APP_PORT'])

151
scribe/__init__.py

@ -0,0 +1,151 @@
from flask import Flask, render_template, request, url_for, current_app
import logging
import os
from io import BytesIO
import secrets
from pathlib import Path
import toml
import functools
from multiprocessing import Process
from .types import MailAddress, ScribeInput, ScribeOutput, SMTPConfig
from .lib import transcribe_url, transcribe_file
from .utils import send_mail, allowed_extension, ALLOWED_EXTENSIONS
def output_mail(config: SMTPConfig, mail_to: MailAddress, content: str, archive: BytesIO) -> None:
title = "Scribe - CEMEA : FIN"
send_mail(config, mail_to, title, content, archive)
def output_local(path: Path, archive: BytesIO) -> None:
filename = secrets.token_hex(16) + ".zip"
with open(path / filename, "wb") as f:
f.write(archive.getbuffer())
def index() -> str:
return render_template("index.html", title=current_app.config['SITE_TITLE'], ALLOWED_EXTENSIONS=ALLOWED_EXTENSIONS)
def why() -> str:
return render_template("why.html", title=current_app.config['SITE_TITLE'] + ' - Pourquoi')
def start() -> str:
# File has priority over URL
if 'file' in request.files and request.files['file'].filename:
file = request.files['file']
# Mypy inference not good enough
assert file.filename is not None
if not allowed_extension(file.filename):
return render_template("result_bad.html", title=current_app.config['SITE_TITLE'] + " - Erreur",
message="Cette extension n'est pas autorisée")
else:
input_location = ScribeInput.File
display_name = file.filename
elif request.form.get('url'):
url = request.form.get('url')
input_location = ScribeInput.YoutubeDl
assert url is not None # mypy inference ...
display_name = url
else:
return render_template("result_bad.html", title=current_app.config['SITE_TITLE'] + " - Erreur",
message="Vous n'avez pas envoyé de fichier ou entré d'URL")
# Let's try to find a model for our language
lang = request.form.get('lang', default='fr')
if lang.upper() in current_app.config['MODELS']:
vosk_uri = current_app.config['MODELS'][lang.upper()]['uri']
else:
# Note : you need to edit HTML to trigger this error
return render_template("result_bad.html", title=current_app.config['SITE_TITLE'] + " - Erreur",
message="Langue invalide")
# Let's prepare our save_archive callback
if current_app.config['OUTPUT_LOCATION'] == ScribeOutput.Mail:
email = request.form.get('email')
if email is None:
return render_template("result_bad.html", title=current_app.config['SITE_TITLE'] + " - Erreur",
message="Vous n'avez pas entré d'email")
mail_to = MailAddress(email)
content = render_template("end.txt", lang=lang, display_name=display_name,
site_url=url_for('index', _external=True))
save_callback = functools.partial(output_mail, current_app.config['SMTP_CONFIG'], mail_to, content)
elif current_app.config['OUTPUT_LOCATION'] == ScribeOutput.Local:
save_callback = functools.partial(output_local, Path(current_app.instance_path))
else:
raise NotImplementedError
# Now let's do the actual Scribe-ing
if input_location == ScribeInput.YoutubeDl:
# This assert can't be triggered (filename is already checked to be a non-empty string)
# But Mypy cannot infer it
assert url is not None
display_name = url
p = Process(target=transcribe_url, args=(url, vosk_uri, save_callback), daemon=True)
p.start()
elif input_location == ScribeInput.File:
# Mypy inference not good enough
assert file.filename is not None
display_name = file.filename
p = Process(target=transcribe_file, args=(file, vosk_uri, save_callback), daemon=True)
p.start()
else:
raise NotImplementedError
# Because transcription can take quite a while, we warn user at start
if current_app.config['OUTPUT_LOCATION'] == ScribeOutput.Mail:
send_mail(current_app.config['SMTP_CONFIG'], mail_to,
title="Scribe - CEMEA : Lancement",
content=render_template("start.txt", lang=lang, display_name=display_name,
site_url=url_for('index', _external=True)),
archive=None)
# All done !
return render_template("result_OK.html", title=current_app.config['SITE_TITLE'] + ' - Résultat',
display_name=display_name)
def create_app() -> Flask:
"""
Flask run (for development) run this automagically, and for development we explicitely tell gunicorn to use this as an
entrypoint
"""
current_app = Flask(__name__)
# Standard init from toml
# We don't use directly current_app.config.from_file because this resolves path according to __init__.py location
# and not based on PWD() for flask run / gunicorn
with open("settings.toml", "r") as f:
current_app.config.update(toml.load(f))
current_app.secret_key = current_app.config['SECRET']
# Let's select where we store output
if current_app.config['USE_MAIL']:
current_app.config['SMTP_CONFIG'] = SMTPConfig(
server=current_app.config['MAIL']['server'],
port=current_app.config['MAIL']['port'],
user=current_app.config['MAIL']['user'],
password=current_app.config['MAIL']['password'],
mail_from=current_app.config['MAIL']['mail_from'])
current_app.config['OUTPUT_LOCATION'] = ScribeOutput.Mail
else:
current_app.config['OUTPUT_LOCATION'] = ScribeOutput.Local
os.makedirs(current_app.instance_path, exist_ok=True)
current_app.config['SMTP_CONFIG'] = None
gunicorn_logger = logging.getLogger('gunicorn.error')
# We are running inside gunicorn, hook ourself to gunicorn
if gunicorn_logger.hasHandlers():
current_app.logger.handlers = gunicorn_logger.handlers
current_app.logger.setLevel(gunicorn_logger.level)
current_app.logger.info("Hooking caracole to gunicorn logger")
# Now let's add our routes
current_app.add_url_rule("/", view_func=index, methods=['GET'])
current_app.add_url_rule("/start/", view_func=start, methods=['POST'])
current_app.add_url_rule("/pourquoi/", view_func=why, methods=['GET'])
return current_app

132
scribe/lib.py

@ -0,0 +1,132 @@
import wave
import json
import datetime
import asyncio
import tempfile
from zipfile import ZipFile
from pathlib import Path
from typing import Union, cast, Callable
from io import BytesIO
import websockets.client
import srt # type: ignore
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from .types import VoskPartial, VoskPhrase, VoskUri
from .utils import download_video, convert_to_wav
async def send_to_vosk(uri: str, buf: BytesIO) -> list[Union[VoskPartial, VoskPhrase]]:
"""
Send a buffer of .wav data to a websocket server
See alphacep/vosk-server/ for example implementations
"""
async with websockets.client.connect(uri) as websocket:
wf = wave.open(buf, "rb")
await websocket.send('{ "config" : { "sample_rate" : %d } }' % (wf.getframerate()))
results = []
buffer_size = int(wf.getframerate() * 0.2) # 0.2 seconds of audio
while True:
data = wf.readframes(buffer_size)
if len(data) == 0:
break
await websocket.send(data)
results.append(json.loads(await websocket.recv()))
await websocket.send('{"eof" : 1}')
# Don't forget the last result which can contain interesting data
results.append(json.loads(await websocket.recv()))
return results
def filter_success(results: list[Union[VoskPartial, VoskPhrase]]) -> list[VoskPhrase]:
"""
Filter the partial result out.
We could do it directly in send_to_vosk, but we might need partial results, one day
"""
return [cast(VoskPhrase, x) for x in results if "result" in x]
def to_phrases(results: list[VoskPhrase], with_timing: bool = False) -> str:
"""
Return all phrases from audio
Optionally, add timings (in the form [HH'MM'SS])
"""
# Very simple, one phrase = one row
if not with_timing:
out = [x["text"] for x in results]
# Let's prepend every phrase with [HH'MM'SS]
else:
out = []
for x in results:
# Either i'm blind, or there is no function in stdlib to do that ?!
total_seconds = int(x["result"][0]["start"])
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
elapsed = datetime.time(hour=hours, minute=minutes, second=seconds).strftime("%H'%M'%S")
out.append(f"[{elapsed}] {x['text']}")
return "\n".join(out)
def to_subtitles(results: list[VoskPhrase]) -> str:
""" Return a subtitle string, with one subtitle = one phrase """
subs = []
for x in results:
start = datetime.timedelta(seconds=x["result"][0]['start'])
end = datetime.timedelta(seconds=x["result"][-1]['end'])
s = srt.Subtitle(None, content=x["text"], start=start, end=end)
subs.append(s)
return srt.compose(subs)
def create_zip(phrases: str, timed_phrases: str, subtitles: str) -> BytesIO:
out = BytesIO()
with ZipFile(out, 'w') as f:
f.writestr("phrases.txt", phrases)
f.writestr("timed_phrases.txt", timed_phrases)
f.writestr("subtitles.srt", subtitles)
return out
def transcribe_audio(audio: BytesIO, vosk_uri: VoskUri) -> BytesIO:
# Let's send data to vosk
success = filter_success(asyncio.run(send_to_vosk(vosk_uri, audio)))
# Let's process resutls into something usable
subtitles = to_subtitles(success)
phrases = to_phrases(success)
timed_phrases = to_phrases(success, with_timing=True)
# Finally, let's send the results
archive = create_zip(phrases, timed_phrases, subtitles)
return archive
def transcribe_url(video_url: str, vosk_uri: VoskUri, save_archive: Callable) -> None:
"""
mail_to is a valid email (but might point to an invalid domain, or invalid user, or ...)
url is a valid URL (but might not point to a url youtube-dl recognizes)
"""
with tempfile.TemporaryDirectory() as temp_folder:
video = download_video(video_url, Path(temp_folder))
audio = convert_to_wav(video)
archive = transcribe_audio(audio, vosk_uri)
save_archive(archive)
def transcribe_file(file: FileStorage, vosk_uri: VoskUri, save_archive: Callable) -> None:
# We use a directory so it's easier to keep the extension (which ffmpeg use)
with tempfile.TemporaryDirectory() as temp_folder:
# This assert can't be triggered (filename is already checked to be a non-empty string)
# But Mypy cannot infer it
assert file.filename is not None
filename = Path(temp_folder) / secure_filename(file.filename)
file.save(filename)
audio = convert_to_wav(filename)
archive = transcribe_audio(audio, vosk_uri)
save_archive(archive)

0
static/april-logo.png → scribe/static/april-logo.png

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

0
static/cemea-logo.png → scribe/static/cemea-logo.png

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

0
static/commonvoice-logo.png → scribe/static/commonvoice-logo.png

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

0
static/scribe.png → scribe/static/scribe.png

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

1
scribe/static/vendor/bulma-0.9.3.min.css vendored

File diff suppressed because one or more lines are too long

34
scribe/static/vendor/fontawesome-free-5.15.4-web/LICENSE.txt vendored

@ -0,0 +1,34 @@
Font Awesome Free License
-------------------------
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
In the Font Awesome Free download, the CC BY 4.0 license applies to all icons
packaged as SVG and JS file types.
# Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL)
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

3
scribe/static/vendor/fontawesome-free-5.15.4-web/attribution.js vendored

@ -0,0 +1,3 @@
console.log(`Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
`)

4616
scribe/static/vendor/fontawesome-free-5.15.4-web/css/all.css vendored

File diff suppressed because it is too large Load Diff

5
scribe/static/vendor/fontawesome-free-5.15.4-web/css/all.min.css vendored

File diff suppressed because one or more lines are too long

15
scribe/static/vendor/fontawesome-free-5.15.4-web/css/brands.css vendored

@ -0,0 +1,15 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Brands';
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../webfonts/fa-brands-400.eot");
src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); }
.fab {
font-family: 'Font Awesome 5 Brands';
font-weight: 400; }

5
scribe/static/vendor/fontawesome-free-5.15.4-web/css/brands.min.css vendored

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands";font-weight:400}

4582
scribe/static/vendor/fontawesome-free-5.15.4-web/css/fontawesome.css vendored

File diff suppressed because it is too large Load Diff

5
scribe/static/vendor/fontawesome-free-5.15.4-web/css/fontawesome.min.css vendored

File diff suppressed because one or more lines are too long

15
scribe/static/vendor/fontawesome-free-5.15.4-web/css/regular.css vendored

@ -0,0 +1,15 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Free';
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../webfonts/fa-regular-400.eot");
src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); }
.far {
font-family: 'Font Awesome 5 Free';
font-weight: 400; }

5
scribe/static/vendor/fontawesome-free-5.15.4-web/css/regular.min.css vendored

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-family:"Font Awesome 5 Free";font-weight:400}

16
scribe/static/vendor/fontawesome-free-5.15.4-web/css/solid.css vendored

@ -0,0 +1,16 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Free';
font-style: normal;
font-weight: 900;
font-display: block;
src: url("../webfonts/fa-solid-900.eot");
src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); }
.fa,
.fas {
font-family: 'Font Awesome 5 Free';
font-weight: 900; }

5
scribe/static/vendor/fontawesome-free-5.15.4-web/css/solid.min.css vendored

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900}

371
scribe/static/vendor/fontawesome-free-5.15.4-web/css/svg-with-js.css vendored

@ -0,0 +1,371 @@
/*!
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
svg:not(:root).svg-inline--fa {
overflow: visible; }
.svg-inline--fa {
display: inline-block;
font-size: inherit;
height: 1em;
overflow: visible;
vertical-align: -.125em; }
.svg-inline--fa.fa-lg {
vertical-align: -.225em; }
.svg-inline--fa.fa-w-1 {
width: 0.0625em; }
.svg-inline--fa.fa-w-2 {
width: 0.125em; }
.svg-inline--fa.fa-w-3 {
width: 0.1875em; }
.svg-inline--fa.fa-w-4 {
width: 0.25em; }
.svg-inline--fa.fa-w-5 {
width: 0.3125em; }
.svg-inline--fa.fa-w-6 {
width: 0.375em; }
.svg-inline--fa.fa-w-7 {
width: 0.4375em; }
.svg-inline--fa.fa-w-8 {
width: 0.5em; }
.svg-inline--fa.fa-w-9 {
width: 0.5625em; }
.svg-inline--fa.fa-w-10 {
width: 0.625em; }
.svg-inline--fa.fa-w-11 {
width: 0.6875em; }
.svg-inline--fa.fa-w-12 {
width: 0.75em; }
.svg-inline--fa.fa-w-13 {
width: 0.8125em; }
.svg-inline--fa.fa-w-14 {
width: 0.875em; }
.svg-inline--fa.fa-w-15 {
width: 0.9375em; }
.svg-inline--fa.fa-w-16 {
width: 1em; }
.svg-inline--fa.fa-w-17 {
width: 1.0625em; }
.svg-inline--fa.fa-w-18 {
width: 1.125em; }
.svg-inline--fa.fa-w-19 {
width: 1.1875em; }
.svg-inline--fa.fa-w-20 {
width: 1.25em; }
.svg-inline--fa.fa-pull-left {
margin-right: .3em;
width: auto; }
.svg-inline--fa.fa-pull-right {
margin-left: .3em;
width: auto; }
.svg-inline--fa.fa-border {
height: 1.5em; }
.svg-inline--fa.fa-li {
width: 2em; }
.svg-inline--fa.fa-fw {
width: 1.25em; }
.fa-layers svg.svg-inline--fa {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0; }
.fa-layers {
display: inline-block;
height: 1em;
position: relative;
text-align: center;
vertical-align: -.125em;
width: 1em; }
.fa-layers svg.svg-inline--fa {
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-text, .fa-layers-counter {
display: inline-block;
position: absolute;
text-align: center; }
.fa-layers-text {
left: 50%;
top: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-counter {
background-color: #ff253a;
border-radius: 1em;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #fff;
height: 1.5em;
line-height: 1;
max-width: 5em;
min-width: 1.5em;
overflow: hidden;
padding: .25em;
right: 0;
text-overflow: ellipsis;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-bottom-right {
bottom: 0;
right: 0;
top: auto;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: bottom right;
transform-origin: bottom right; }
.fa-layers-bottom-left {
bottom: 0;
left: 0;
right: auto;
top: auto;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: bottom left;
transform-origin: bottom left; }
.fa-layers-top-right {
right: 0;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-top-left {
left: 0;
right: auto;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top left;
transform-origin: top left; }
.fa-lg {
font-size: 1.33333em;
line-height: 0.75em;
vertical-align: -.0667em; }