diff --git a/pretalx_broadcast_tools/apps.py b/pretalx_broadcast_tools/apps.py index 4834890..45ab95d 100644 --- a/pretalx_broadcast_tools/apps.py +++ b/pretalx_broadcast_tools/apps.py @@ -22,3 +22,4 @@ class PluginApp(AppConfig): def ready(self): from . import signals # NOQA + from . import tasks # NOQA diff --git a/pretalx_broadcast_tools/assets/titilium-web-regular.LICENSE b/pretalx_broadcast_tools/assets/titilium-web-regular.LICENSE new file mode 100644 index 0000000..9a5431d --- /dev/null +++ b/pretalx_broadcast_tools/assets/titilium-web-regular.LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2009-2011 by Accademia di Belle Arti di Urbino and students +of MA course of Visual design. Some rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pretalx_broadcast_tools/assets/titilium-web-regular.ttf b/pretalx_broadcast_tools/assets/titilium-web-regular.ttf new file mode 100644 index 0000000..e0e2dc8 Binary files /dev/null and b/pretalx_broadcast_tools/assets/titilium-web-regular.ttf differ diff --git a/pretalx_broadcast_tools/forms.py b/pretalx_broadcast_tools/forms.py index 12ebaaf..bbf32eb 100644 --- a/pretalx_broadcast_tools/forms.py +++ b/pretalx_broadcast_tools/forms.py @@ -16,12 +16,22 @@ class BroadcastToolsSettingsForm(I18nFormMixin, HierarkeyForm): broadcast_tools_lower_thirds_info_string = I18nFormField( help_text=_( "Will only be shown if there's a talk running. You may use " - "the place holders mentioned below." + "the place holders mentioned below. The info line will be shown " + "on the bottom right side of the lower third. Setting this to an " + "empty string will hide the line entirely." ), label=_("Info line"), required=False, widget=I18nTextInput, ) + broadcast_tools_lower_thirds_export_voctomix = BooleanField( + help_text=_( + "If checked, pretalx will periodically generate voctomix-compatible " + "lower thirds images and make them available as .tar.gz." + ), + label=_("Generate voctomix lower thirds"), + required=False, + ) broadcast_tools_room_info_lower_content = ChoiceField( choices=( diff --git a/pretalx_broadcast_tools/locale/de_DE/LC_MESSAGES/django.po b/pretalx_broadcast_tools/locale/de_DE/LC_MESSAGES/django.po index d5dd4be..b7b5017 100644 --- a/pretalx_broadcast_tools/locale/de_DE/LC_MESSAGES/django.po +++ b/pretalx_broadcast_tools/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-10-31 21:10+0100\n" +"POT-Creation-Date: 2024-11-03 13:40+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -74,16 +74,31 @@ msgstr "\"Derzeit kein Vortrag\"-Informationen" #: forms.py:18 msgid "" "Will only be shown if there's a talk running. You may use the place holders " -"mentioned below." +"mentioned below. The info line will be shown on the bottom right side of the " +"lower third. Setting this to an empty string will hide the line entirely." msgstr "" "Wird nur angezeigt, wenn derzeit ein Vortrag läuft. Du kannst die oben " -"genannten Platzhalter benutzen." +"genannten Platzhalter benutzen. Die Info-Zeile wird unten rechts in den " +"Bauchbinden angezeigt. Wenn das Feld leer ist, wird die Zeile automatisch " +"ausgeblendet." -#: forms.py:21 +#: forms.py:23 msgid "Info line" msgstr "Info-Zeile" -#: forms.py:37 +#: forms.py:29 +msgid "" +"If checked, pretalx will periodically generate voctomix-compatible lower " +"thirds images and make them available as .tar.gz." +msgstr "" +"Wenn aktiviert, wird pretalx automatisch voctomix-kompatible bauchbinden " +"generieren und diese als .tar.gz zur Verfügung stellen." + +#: forms.py:32 +msgid "Generate voctomix lower thirds" +msgstr "Erzeuge voctomix-Bauchbinden" + +#: forms.py:47 msgid "" "If a talk is running, the room info page will always show the talk title and " "the list of speakers. The content below is configurable here." @@ -92,11 +107,11 @@ msgstr "" "und die Liste der Vortragenden an. Der Inhalt unterhalb dessen ist hier " "konfigurierbar." -#: forms.py:41 +#: forms.py:51 msgid "lower content" msgstr "Unterer Inhalt" -#: forms.py:46 +#: forms.py:56 msgid "" "If no talk is running in the room, show the time and title of the next talk " "in the room." @@ -104,11 +119,11 @@ msgstr "" "Wenn derzeit kein Vortrag läuft, soll die Startzeit und der Titel des " "nächsten Vortrags angezeigt werden." -#: forms.py:49 +#: forms.py:59 msgid "Show next talk" msgstr "Zeige nächsten Vortrag" -#: forms.py:55 +#: forms.py:65 msgid "" "If checked, the value of the 'internal notes' field in a submission will get " "added to the pdf export." @@ -116,22 +131,22 @@ msgstr "" "Wenn aktiviert, wird der Inhalt des Feldes 'Interne Notizen' im PDF-Export " "angezeigt." -#: forms.py:58 +#: forms.py:68 msgid "Show internal notes in pdf export" msgstr "Zeige interne Notizen im PDF-Export" -#: forms.py:63 +#: forms.py:73 msgid "" "If checked, 'do not record' talks will not generate a page in the pdf export." msgstr "" "Wenn aktiviert, werden Vorträge mit 'Zeichnet meinen Vortrag nicht auf'-Flag " "keine Seite im PDF-Export generieren." -#: forms.py:66 +#: forms.py:76 msgid "Ignore 'do not record' talks when generating pdf" msgstr "Ignoriere 'Zeichnet meinen Vortrag nicht auf'-Vorträge im PDF-Export" -#: forms.py:71 +#: forms.py:81 msgid "" "Comma-Separated list of question ids to include in pdf export. If empty, no " "questions will get added." @@ -139,11 +154,11 @@ msgstr "" "Komma-separierte Liste von Fragen, die im PDF-Export eingebunden werden. " "Falls dieses Feld leer ist, werden keine Fragen im PDF angezeigt." -#: forms.py:74 +#: forms.py:84 msgid "Questions to include" msgstr "Eingebundene Fragen" -#: forms.py:79 +#: forms.py:89 msgid "" "Additional content to print onto the PDF export. Will get printed as-is. You " "may use the place holders mentioned below." @@ -151,7 +166,7 @@ msgstr "" "Zusätzlicher Inhalt, der auf dem PDF-Export angezeigt wird. Wird wie hier " "eingegeben angezeigt. Du kannst die oben genannten Platzhalter benutzen." -#: forms.py:83 +#: forms.py:93 msgid "Additional text" msgstr "Zusätzlicher Text" @@ -167,36 +182,40 @@ msgstr "" msgid "broadcasting tools" msgstr "Broadcasting-Tools" -#: templates/pretalx_broadcast_tools/orga.html:14 +#: templates/pretalx_broadcast_tools/orga.html:13 msgid "room" msgstr "Raum" -#: templates/pretalx_broadcast_tools/orga.html:15 +#: templates/pretalx_broadcast_tools/orga.html:14 msgid "Feature" msgstr "Funktion" -#: templates/pretalx_broadcast_tools/orga.html:22 +#: templates/pretalx_broadcast_tools/orga.html:21 #, fuzzy #| msgid "Lower thirds" msgid "Lower Thirds" msgstr "Bauchbinden" -#: templates/pretalx_broadcast_tools/orga.html:23 +#: templates/pretalx_broadcast_tools/orga.html:22 #, fuzzy #| msgid "Room info" msgid "Room Info" msgstr "Raum-Information" -#: templates/pretalx_broadcast_tools/orga.html:30 +#: templates/pretalx_broadcast_tools/orga.html:27 +msgid "Download voctomix-compatible lower thirds images" +msgstr "Lade voctomix-kompatible Bauchbinden-Bilder herunter" + +#: templates/pretalx_broadcast_tools/orga.html:29 msgid "Placeholders" msgstr "Platzhalter" -#: templates/pretalx_broadcast_tools/orga.html:31 +#: templates/pretalx_broadcast_tools/orga.html:30 msgid "" "pretalx will automatically replace some placeholders in your custom content:" msgstr "pretalx ersetzt automatisch einige Platzhalter in deinen Texten:" -#: templates/pretalx_broadcast_tools/orga.html:34 +#: templates/pretalx_broadcast_tools/orga.html:33 msgid "" "talk code (MUX9U3 for example) - most useful in combination " "with pretalx-proposal-redirects or something like that" @@ -204,54 +223,46 @@ msgstr "" "Vortrags-Slug (zum Beispiel MUX9U3) - am hilfreichsten in " "Kombination mit pretalx-proposal-redirects oder ähnlichen Plugins" -#: templates/pretalx_broadcast_tools/orga.html:37 +#: templates/pretalx_broadcast_tools/orga.html:36 msgid "The event slug" msgstr "Der Event-Slug" -#: templates/pretalx_broadcast_tools/orga.html:40 +#: templates/pretalx_broadcast_tools/orga.html:39 msgid "URL to the talk feedback page." msgstr "Adresse der Vortrags-Feedback-Seite" -#: templates/pretalx_broadcast_tools/orga.html:43 +#: templates/pretalx_broadcast_tools/orga.html:42 msgid "The talk slug" msgstr "Der Vortrags-Slug" -#: templates/pretalx_broadcast_tools/orga.html:46 +#: templates/pretalx_broadcast_tools/orga.html:45 msgid "URL to the talk detail page." msgstr "Adresse der Vortrags-Seite" -#: templates/pretalx_broadcast_tools/orga.html:48 +#: templates/pretalx_broadcast_tools/orga.html:47 msgid "or" msgstr "oder" -#: templates/pretalx_broadcast_tools/orga.html:49 +#: templates/pretalx_broadcast_tools/orga.html:48 msgid "Track name in plain text or coloured using the track colour." msgstr "Track-Name in einfachem Text oder in der Track-Farbe eingefärbt." -#: templates/pretalx_broadcast_tools/orga.html:52 +#: templates/pretalx_broadcast_tools/orga.html:51 msgid "Settings" msgstr "Einstellungen" -#: templates/pretalx_broadcast_tools/orga.html:55 +#: templates/pretalx_broadcast_tools/orga.html:54 msgid "Lower thirds" msgstr "Bauchbinden" -#: templates/pretalx_broadcast_tools/orga.html:59 -msgid "" -"The info line will be shown on the bottom right side of your lower third. If " -"you set it to an empty string, it will automatically hide itself." -msgstr "" -"Die Info-Zeile wird unten rechts in den Bauchbinden angezeigt. Wenn das Feld " -"leer ist, wird die Zeile automatisch ausgeblendet." - -#: templates/pretalx_broadcast_tools/orga.html:63 +#: templates/pretalx_broadcast_tools/orga.html:62 msgid "Room info" msgstr "Raum-Information" -#: templates/pretalx_broadcast_tools/orga.html:70 +#: templates/pretalx_broadcast_tools/orga.html:69 msgid "PDF export" msgstr "PDF-Export" -#: templates/pretalx_broadcast_tools/orga.html:86 +#: templates/pretalx_broadcast_tools/orga.html:85 msgid "Save" msgstr "Speichern" diff --git a/pretalx_broadcast_tools/locale/fr_FR/LC_MESSAGES/django.po b/pretalx_broadcast_tools/locale/fr_FR/LC_MESSAGES/django.po index 04efdec..0b98d27 100644 --- a/pretalx_broadcast_tools/locale/fr_FR/LC_MESSAGES/django.po +++ b/pretalx_broadcast_tools/locale/fr_FR/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-10-31 21:10+0100\n" +"POT-Creation-Date: 2024-11-03 13:40+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -71,69 +71,80 @@ msgstr "" #: forms.py:18 msgid "" "Will only be shown if there's a talk running. You may use the place holders " -"mentioned below." +"mentioned below. The info line will be shown on the bottom right side of the " +"lower third. Setting this to an empty string will hide the line entirely." msgstr "" -#: forms.py:21 +#: forms.py:23 msgid "Info line" msgstr "" -#: forms.py:37 +#: forms.py:29 +msgid "" +"If checked, pretalx will periodically generate voctomix-compatible lower " +"thirds images and make them available as .tar.gz." +msgstr "" + +#: forms.py:32 +msgid "Generate voctomix lower thirds" +msgstr "" + +#: forms.py:47 msgid "" "If a talk is running, the room info page will always show the talk title and " "the list of speakers. The content below is configurable here." msgstr "" -#: forms.py:41 +#: forms.py:51 msgid "lower content" msgstr "" -#: forms.py:46 +#: forms.py:56 msgid "" "If no talk is running in the room, show the time and title of the next talk " "in the room." msgstr "" -#: forms.py:49 +#: forms.py:59 msgid "Show next talk" msgstr "" -#: forms.py:55 +#: forms.py:65 msgid "" "If checked, the value of the 'internal notes' field in a submission will get " "added to the pdf export." msgstr "" -#: forms.py:58 +#: forms.py:68 msgid "Show internal notes in pdf export" msgstr "" -#: forms.py:63 +#: forms.py:73 msgid "" "If checked, 'do not record' talks will not generate a page in the pdf export." msgstr "" -#: forms.py:66 +#: forms.py:76 msgid "Ignore 'do not record' talks when generating pdf" msgstr "" -#: forms.py:71 +#: forms.py:81 msgid "" "Comma-Separated list of question ids to include in pdf export. If empty, no " "questions will get added." msgstr "" -#: forms.py:74 +#: forms.py:84 msgid "Questions to include" msgstr "" -#: forms.py:79 +#: forms.py:89 msgid "" "Additional content to print onto the PDF export. Will get printed as-is. You " "may use the place holders mentioned below." msgstr "" -#: forms.py:83 +#: forms.py:93 msgid "Additional text" msgstr "" @@ -149,83 +160,81 @@ msgstr "" msgid "broadcasting tools" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:14 +#: templates/pretalx_broadcast_tools/orga.html:13 msgid "room" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:15 +#: templates/pretalx_broadcast_tools/orga.html:14 msgid "Feature" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:22 +#: templates/pretalx_broadcast_tools/orga.html:21 msgid "Lower Thirds" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:23 +#: templates/pretalx_broadcast_tools/orga.html:22 msgid "Room Info" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:30 +#: templates/pretalx_broadcast_tools/orga.html:27 +msgid "Download voctomix-compatible lower thirds images" +msgstr "" + +#: templates/pretalx_broadcast_tools/orga.html:29 msgid "Placeholders" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:31 +#: templates/pretalx_broadcast_tools/orga.html:30 msgid "" "pretalx will automatically replace some placeholders in your custom content:" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:34 +#: templates/pretalx_broadcast_tools/orga.html:33 msgid "" "talk code (MUX9U3 for example) - most useful in combination " "with pretalx-proposal-redirects or something like that" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:37 +#: templates/pretalx_broadcast_tools/orga.html:36 msgid "The event slug" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:40 +#: templates/pretalx_broadcast_tools/orga.html:39 msgid "URL to the talk feedback page." msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:43 +#: templates/pretalx_broadcast_tools/orga.html:42 msgid "The talk slug" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:46 +#: templates/pretalx_broadcast_tools/orga.html:45 msgid "URL to the talk detail page." msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:48 +#: templates/pretalx_broadcast_tools/orga.html:47 msgid "or" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:49 +#: templates/pretalx_broadcast_tools/orga.html:48 msgid "Track name in plain text or coloured using the track colour." msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:52 +#: templates/pretalx_broadcast_tools/orga.html:51 msgid "Settings" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:55 +#: templates/pretalx_broadcast_tools/orga.html:54 msgid "Lower thirds" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:59 -msgid "" -"The info line will be shown on the bottom right side of your lower third. If " -"you set it to an empty string, it will automatically hide itself." -msgstr "" - -#: templates/pretalx_broadcast_tools/orga.html:63 +#: templates/pretalx_broadcast_tools/orga.html:62 msgid "Room info" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:70 +#: templates/pretalx_broadcast_tools/orga.html:69 msgid "PDF export" msgstr "" -#: templates/pretalx_broadcast_tools/orga.html:86 +#: templates/pretalx_broadcast_tools/orga.html:85 msgid "Save" msgstr "" diff --git a/pretalx_broadcast_tools/management/commands/export_voctomix_lower_thirds.py b/pretalx_broadcast_tools/management/commands/export_voctomix_lower_thirds.py new file mode 100644 index 0000000..87f1bd5 --- /dev/null +++ b/pretalx_broadcast_tools/management/commands/export_voctomix_lower_thirds.py @@ -0,0 +1,336 @@ +import logging +import tarfile +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django_scopes import scope, scopes_disabled +from PIL import Image, ImageDraw, ImageFont +from pretalx.agenda.management.commands.export_schedule_html import delete_directory +from pretalx.event.models import Event + +from pretalx_broadcast_tools.utils.placeholders import placeholders + +IMG_WIDTH = 1920 # px +IMG_HEIGHT = 1080 # px + +FONT_SIZE_TITLE = 30 # px +FONT_SIZE_SPEAKER = 25 # px +FONT_SIZE_INFOLINE = 18 # px +FONT_FILE = ( + Path(__file__).resolve().parent.parent.parent + / "assets" + / "titilium-web-regular.ttf" +) + +BOX_PADDING = 10 # px + +BOX_WIDTH = int(IMG_WIDTH * 0.8) +BOX_BOTTOM = int(IMG_HEIGHT - (IMG_HEIGHT * 0.1)) +BOX_LEFT = int((IMG_WIDTH - BOX_WIDTH) / 2) + + +def get_export_path(event): + return settings.HTMLEXPORT_ROOT / event.slug / "broadcast-tools" + + +def get_export_targz_path(event): + return get_export_path(event).with_suffix(".voctomix.tar.gz") + + +def make_targz(generated_files, targz_path): + tmp_name = targz_path.with_suffix(".tmp") + tmp_name.unlink(missing_ok=True) + with tarfile.open(tmp_name, "w:gz") as tar: + for file in generated_files: + tar.add(file, arcname=file.name) + tmp_name.rename(targz_path) + + +class VoctomixLowerThirdsExporter: + def __init__(self, event, tmp_dir): + self.log = logging.getLogger(event.slug) + self.event = event + self.tmp_dir = tmp_dir + self.exported = set() + + if event.primary_color: + self.primary_colour = self._hex2rgb(event.primary_color) + else: + # pretalx.settings.DEFAULT_EVENT_PRIMARY_COLOR + self.primary__color = (58, 165, 124) + + self.infoline = event.settings.broadcast_tools_lower_thirds_info_string or "" + + self.font_title = ImageFont.truetype( + FONT_FILE, + size=FONT_SIZE_TITLE, + encoding="unic", + ) + self.font_speaker = ImageFont.truetype( + FONT_FILE, + size=FONT_SIZE_SPEAKER, + encoding="unic", + ) + self.font_infoline = ImageFont.truetype( + FONT_FILE, + size=FONT_SIZE_INFOLINE, + encoding="unic", + ) + + def _hex2rgb(self, hex_value): + hex_value = hex_value.lstrip("#") + # black insists this should have spaces around the :, but flake8 + # complains about spaces around the :, soooooo .... + return tuple(int(hex_value[i : i + 2], 16) for i in (0, 2, 4)) # NOQA + + def _fit_text(self, input_text, font, max_width): + words = [i.strip() for i in input_text.split()] + lines = [] + line = [] + for word in words: + new_line = " ".join([*line, word]) + _, _, w, _ = font.getbbox(new_line) + if w > max_width: + # append old line to list of lines, then start new line with + # current word + lines.append(" ".join(line)) + line = [word] + elif word.endswith(":"): + lines.append(new_line) + line = [] + else: + line.append(word) + + if line: + lines.append(" ".join(line)) + return lines + + def _get_infoline(self, talk): + infoline = self.infoline.localize(self.event.locale).format( + **placeholders( + self.event, + talk, + ) + ) + _, _, w, _ = self.font_infoline.getbbox(infoline) + return w, infoline + + def export_speaker(self, talk, speaker): + img = Image.new( + mode="RGBA", + size=(IMG_WIDTH, IMG_HEIGHT), + color=(0, 0, 0, 0), + ) + + speaker_text = self._fit_text( + speaker.get_display_name(), + self.font_speaker, + BOX_WIDTH, + ) + infoline_width, infoline_text = self._get_infoline(talk) + + y_pos = BOX_BOTTOM - BOX_PADDING + if speaker_text: + y_pos -= len(speaker_text) * FONT_SIZE_SPEAKER + if infoline_text: + y_pos -= FONT_SIZE_INFOLINE + + draw = ImageDraw.Draw(img) + draw.rectangle( + [ + (BOX_LEFT - BOX_PADDING, y_pos), + (BOX_LEFT + BOX_WIDTH + BOX_PADDING, BOX_BOTTOM + BOX_PADDING), + ], + fill=self.primary_colour, + ) + if talk.submission.track and talk.submission.track.color: + draw.rectangle( + [ + (BOX_LEFT - BOX_PADDING, BOX_BOTTOM + BOX_PADDING), + ( + BOX_LEFT + BOX_WIDTH + BOX_PADDING, + BOX_BOTTOM + (BOX_PADDING * 2), + ), + ], + fill=self._hex2rgb(talk.submission.track.color), + ) + + for line in speaker_text: + draw.text( + (BOX_LEFT, y_pos), + line, + font=self.font_speaker, + fill=(255, 255, 255), + ) + y_pos += FONT_SIZE_SPEAKER + + if infoline_text: + draw.text( + (BOX_LEFT + BOX_WIDTH - infoline_width, y_pos), + infoline_text, + font=self.font_infoline, + fill=(255, 255, 255), + ) + + filename = self.tmp_dir / f"event_{talk.submission_id}_person_{speaker.id}.png" + img.save(filename) + self.log.debug( + f"Generated single-speaker image for {speaker.get_display_name()!r} " + "of talk {talk.submission.title!r}, saved as {filename}" + ) + return filename + + def export_talk(self, talk): + img = Image.new( + mode="RGBA", + size=(IMG_WIDTH, IMG_HEIGHT), + color=(0, 0, 0, 0), + ) + + title_text = self._fit_text(talk.submission.title, self.font_title, BOX_WIDTH) + speaker_text = self._fit_text( + ", ".join( + [person.get_display_name() for person in talk.submission.speakers.all()] + ), + self.font_speaker, + BOX_WIDTH, + ) + infoline_width, infoline_text = self._get_infoline(talk) + + y_pos = BOX_BOTTOM - BOX_PADDING + if title_text: + y_pos -= len(title_text) * FONT_SIZE_TITLE + if speaker_text: + y_pos -= len(speaker_text) * FONT_SIZE_SPEAKER + if title_text and speaker_text: + y_pos -= BOX_PADDING + if infoline_text: + y_pos -= FONT_SIZE_INFOLINE + + draw = ImageDraw.Draw(img) + draw.rectangle( + [ + (BOX_LEFT - BOX_PADDING, y_pos), + (BOX_LEFT + BOX_WIDTH + BOX_PADDING, BOX_BOTTOM + BOX_PADDING), + ], + fill=self.primary_colour, + ) + if talk.submission.track and talk.submission.track.color: + draw.rectangle( + [ + (BOX_LEFT - BOX_PADDING, BOX_BOTTOM + BOX_PADDING), + ( + BOX_LEFT + BOX_WIDTH + BOX_PADDING, + BOX_BOTTOM + (BOX_PADDING * 2), + ), + ], + fill=self._hex2rgb(talk.submission.track.color), + ) + + for line in title_text: + draw.text( + (BOX_LEFT, y_pos), + line, + font=self.font_title, + fill=(255, 255, 255), + ) + y_pos += FONT_SIZE_TITLE + + if title_text and speaker_text: + y_pos += BOX_PADDING + + for line in speaker_text: + draw.text( + (BOX_LEFT, y_pos), + line, + font=self.font_speaker, + fill=(255, 255, 255), + ) + y_pos += FONT_SIZE_SPEAKER + + if infoline_text: + draw.text( + (BOX_LEFT + BOX_WIDTH - infoline_width, y_pos), + infoline_text, + font=self.font_infoline, + fill=(255, 255, 255), + ) + + filename = self.tmp_dir / f"event_{talk.submission_id}_persons.png" + img.save(filename) + self.log.debug( + f"Generated image for talk {talk.submission.title!r}, saved as {filename}" + ) + return filename + + def export(self): + generated_files = set() + if not self.event.current_schedule: + raise CommandError( + f"event {self.event.slug} ({self.event.name}) does not have a schedule to be exported!" + ) + + self.log.info( + f"Generating voctomix-compatible lower thirds for event {self.event.name}" + ) + + for talk in self.event.current_schedule.talks.filter( + is_visible=True + ).select_related("submission"): + if talk.id in self.exported: + # account for talks that are scheduled multiple times + self.log.warning( + f"Talk {talk.submission.title!r} was already generated, skipping. " + "(Possibly scheduled multiple times?)" + ) + continue + + self.log.info(f"Generating image(s) for talk {talk.submission.title!r}") + generated_files.add(self.export_talk(talk)) + for speaker in talk.submission.speakers.all(): + generated_files.add(self.export_speaker(talk, speaker)) + self.exported.add(talk.id) + + return generated_files + + +class Command(BaseCommand): + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument("event", type=str) + parser.add_argument("--no-delete-source-files", action="store_true") + + def handle(self, *args, **options): + event_slug = options.get("event") + + with scopes_disabled(): + try: + event = Event.objects.get(slug__iexact=event_slug) + except Event.DoesNotExist: + raise CommandError(f"could not find event with slug {event_slug!r}") + + with scope(event=event): + logging.info(f"Exporting {event.name}") + + export_dir = get_export_path(event) + targz_path = get_export_targz_path(event) + + delete_directory(export_dir) + # for the first export of the conference, the "broadcast-tools" + # directory does not exist + export_dir.mkdir(parents=True) + + try: + exporter = VoctomixLowerThirdsExporter(event, export_dir) + generated_files = exporter.export() + make_targz(generated_files, targz_path) + except Exception: + logging.exception(f"Export of {event.name} failed") + else: + logging.info( + f"Export of {event.name} succeeded, export available at {targz_path}" + ) + finally: + if not options.get("no_delete_source_files"): + delete_directory(export_dir) diff --git a/pretalx_broadcast_tools/tasks.py b/pretalx_broadcast_tools/tasks.py new file mode 100644 index 0000000..c09d14b --- /dev/null +++ b/pretalx_broadcast_tools/tasks.py @@ -0,0 +1,78 @@ +import logging +from datetime import timedelta + +from django.dispatch import receiver +from django.utils.timezone import now +from django_scopes import scope, scopes_disabled +from pretalx.celery_app import app +from pretalx.common.signals import periodic_task +from pretalx.event.models import Event + +LOG = logging.getLogger(__name__) + + +@app.task(name="pretalx_broadcast_tools.export_voctomix_lower_thirds") +def export_voctomix_lower_thirds(*, event_id): + from django.core.management import call_command + + with scopes_disabled(): + event = Event.objects.filter(pk=event_id).first() + if not event: + LOG.error(f"Could not find event {event_id=} for export") + return + + with scope(event=event): + if not event.current_schedule: + LOG.error(f"event {event.slug} does not have schedule, can't export") + return + + call_command( + "export_voctomix_lower_thirds", + event.slug, + ) + + +@app.task(name="pretalx_broadcast_tools.periodic_voctomix_export") +def task_periodic_voctomix_export(*, event_slug): + from pretalx_broadcast_tools.management.commands.export_voctomix_lower_thirds import ( + get_export_targz_path, + ) + + with scopes_disabled(): + event = Event.objects.filter(slug=event_slug).first() + + with scope(event=event): + if ( + not event.settings.broadcast_tools_lower_thirds_export_voctomix + or not event.current_schedule + ): + return + + targz_path = get_export_targz_path(event) + needs_rebuild = False + last_rebuild = event.cache.get("broadcast_tools_last_voctomix_export") + _now = now() + if not targz_path.exists(): + needs_rebuild = True + if not last_rebuild or _now - last_rebuild >= timedelta(hours=1): + needs_rebuild = True + if event.cache.get("broadcast_tools_force_new_voctomix_export"): + needs_rebuild = True + + if needs_rebuild: + event.cache.delete("broadcast_tools_force_new_voctomix_export") + event.cache.set("broadcast_tools_last_voctomix_export", _now, None) + export_voctomix_lower_thirds.apply_async( + kwargs={"event_id": event.id}, ignore_result=True + ) + + +@receiver(periodic_task) +def periodic_event_services(sender, **kwargs): + for event in Event.objects.all(): + with scope(event=event): + if (event.date_to + timedelta(days=2)) < now().date(): + continue + task_periodic_voctomix_export.apply_async( + kwargs={"event_slug": event.slug}, ignore_result=True + ) diff --git a/pretalx_broadcast_tools/templates/pretalx_broadcast_tools/orga.html b/pretalx_broadcast_tools/templates/pretalx_broadcast_tools/orga.html index 1804bbf..a184c51 100644 --- a/pretalx_broadcast_tools/templates/pretalx_broadcast_tools/orga.html +++ b/pretalx_broadcast_tools/templates/pretalx_broadcast_tools/orga.html @@ -24,6 +24,7 @@ {% endfor %} +

{% translate "Download voctomix-compatible lower thirds images" %}

{% translate "Placeholders" %}

{% translate "pretalx will automatically replace some placeholders in your custom content:" %}

@@ -54,7 +55,7 @@ {{ form.broadcast_tools_lower_thirds_no_talk_info.as_field_group }} {{ form.broadcast_tools_lower_thirds_info_string.as_field_group }} -

{% translate "The info line will be shown on the bottom right side of your lower third. If you set it to an empty string, it will automatically hide itself." %}

+ {{ form.broadcast_tools_lower_thirds_export_voctomix.as_field_group }}
diff --git a/pretalx_broadcast_tools/urls.py b/pretalx_broadcast_tools/urls.py index 554348c..1d68954 100644 --- a/pretalx_broadcast_tools/urls.py +++ b/pretalx_broadcast_tools/urls.py @@ -5,6 +5,7 @@ from .views.orga import BroadcastToolsOrgaView from .views.qr import BroadcastToolsFeedbackQrCodeSvg, BroadcastToolsPublicQrCodeSvg from .views.schedule import BroadcastToolsScheduleView from .views.static_html import BroadcastToolsLowerThirdsView, BroadcastToolsRoomInfoView +from .views.voctomix_export import BroadcastToolsLowerThirdsVoctomixDownloadView urlpatterns = [ path( @@ -26,6 +27,11 @@ urlpatterns = [ BroadcastToolsLowerThirdsView.as_view(), name="lowerthirds", ), + path( + "lower-thirds.voctomix.tar.gz", + BroadcastToolsLowerThirdsVoctomixDownloadView.as_view(), + name="lowerthirds_voctomix_download", + ), path( "feedback-qr/.svg", BroadcastToolsFeedbackQrCodeSvg.as_view(), diff --git a/pretalx_broadcast_tools/views/voctomix_export.py b/pretalx_broadcast_tools/views/voctomix_export.py new file mode 100644 index 0000000..50f8f0f --- /dev/null +++ b/pretalx_broadcast_tools/views/voctomix_export.py @@ -0,0 +1,22 @@ +from django.http import FileResponse, Http404 +from django.views import View +from pretalx.common.text.path import safe_filename +from pretalx.common.views.mixins import EventPermissionRequired + +from pretalx_broadcast_tools.management.commands.export_voctomix_lower_thirds import ( + get_export_targz_path, +) + + +class BroadcastToolsLowerThirdsVoctomixDownloadView(EventPermissionRequired, View): + permission_required = "agenda.view_schedule" + + def get(self, request, *args, **kwargs): + targz_path = get_export_targz_path(self.request.event) + if not targz_path.exists(): + raise Http404() + response = FileResponse(open(targz_path, "rb"), as_attachment=True) + response["Content-Disposition"] = ( + f"attachment; filename={safe_filename(targz_path.name)}" + ) + return response