Summary (735 words): Emacs is a handy audio/video editor. subed-record can combine multiple audio files and images to create multiple output videos.
It's nice to feel like you're saying someone's name correctly. We ask EmacsConf speakers to introduce themselves in the first few seconds of their video, but people often forget to do that, so that's okay. We started recording introductions for EmacsConf 2022 so that stream hosts don't have to worry about figuring out pronunciation while they're live. Here's how I used subed-record to turn my recordings into lots of little videos.
First, I generated the title images by using Emacs Lisp to replace
text in a template SVG and then using Inkscape to convert the SVG into
a PNG. Each image showed information for the previous talk as well as
the upcoming talk. (emacsconf-stream-generate-in-between-pages
)
Then I generated the text for each talk based on the title, the
speaker names, pronunciation notes, pronouns, and type of Q&A. Each
introduction generally followed the pattern, "Next we have title by
speakers. Details about Q&A." (emacsconf-pad-expand-intro
and
emacsconf-subed-intro-subtitles
below)
00:00:00.000 --> 00:00:00.999 #+OUTPUT: sat-open.webm [[file:/home/sacha/proj/emacsconf/2023/assets/in-between/sat-open.svg.png]] Next, we have "Saturday opening remarks". 00:00:05.000 --> 00:00:04.999 #+OUTPUT: adventure.webm [[file:/home/sacha/proj/emacsconf/2023/assets/in-between/adventure.svg.png]] Next, we have "An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp", by Chung-hong Chan. He will answer questions via Etherpad.
I copied the text into an Org note in my inbox, which Syncthing copied over to the Orgzly Revived app on my Android phone. I used Google Recorder to record the audio. I exported the m4a audio file and a rough transcript, copied them back via Syncthing, and used subed-record to edit the audio into a clean audio file without oopses.
Each intro had a set of captions that started with a NOTE
comment.
The NOTE
comment specified the following:
#+AUDIO:
: the audio source to use for the timestamped captions
that follow[[file:...]]
: the title image I generated for each talk. When
subed-record-compile-video
sees a comment with a link to an image,
video, or animated GIF, it takes that visual and uses it for the
span of time until the next visual.#+OUTPUT:
the file to create.NOTE #+OUTPUT: hyperdrive.webm [[file:/home/sacha/proj/emacsconf/2023/assets/in-between/hyperdrive.svg.png]] #+AUDIO: intros-2023-11-21-cleaned.opus 00:00:15.680 --> 00:00:17.599 Next, we have "hyperdrive.el: 00:00:17.600 --> 00:00:21.879 Peer-to-peer filesystem in Emacs", by Joseph Turner 00:00:21.880 --> 00:00:25.279 and Protesilaos Stavrou (also known as Prot). 00:00:25.280 --> 00:00:27.979 Joseph will answer questions via BigBlueButton, 00:00:27.980 --> 00:00:31.080 and Prot might be able to join depending on the weather. 00:00:31.081 --> 00:00:33.439 You can join using the URL from the talk page 00:00:33.440 --> 00:00:36.320 or ask questions through Etherpad or IRC. NOTE #+OUTPUT: steno.webm [[file:/home/sacha/proj/emacsconf/2023/assets/in-between/steno.svg.png]] #+AUDIO: intros-2023-11-19-cleaned.opus 00:03:23.260 --> 00:03:25.480 Next, we have "Programming with steno", 00:03:25.481 --> 00:03:27.700 by Daniel Alejandro Tapia. NOTE #+AUDIO: intro-2023-11-29-cleaned.opus 00:00:13.620 --> 00:00:16.580 You can ask your questions via Etherpad and IRC. 00:00:16.581 --> 00:00:18.079 We'll send them to the speaker 00:00:18.080 --> 00:00:19.919 and post the answers in the talk page 00:00:19.920 --> 00:00:21.320 after the conference.
I could then call subed-record-compile-video
to create the videos
for all the intros, or mark a region with C-SPC
and then
subed-record-compile-video
only the intros inside that region.
Using Emacs to edit the audio and compile videos worked out really well because it made it easy to change things.
#+AUDIO:
comment specifying which file I want to
take the audio from, paste it into my main intros.vtt
, and
recompile the video.intros.vtt
(making sure to set
#+AUDIO
to the file if it came from a different take), and
recompile the video.And of course, all the videos were captioned. Bonus!
So that's how using Emacs to edit and compile simple videos saved me a lot of time. I don't know how I'd handle this otherwise. 47 video projects that might all need to be updated if, say, I changed the template? Yikes. Much better to work with text. Here are the technical details.
I used Inkscape to add IDs to our template SVG so that I could edit them with Emacs Lisp. From emacsconf-stream.el:
(defun emacsconf-stream-generate-in-between-pages (&optional info) "Generate the title images." (interactive) (setq info (or emacsconf-schedule-draft (emacsconf-publish-prepare-for-display (emacsconf-filter-talks (or info (emacsconf-get-talk-info)))))) (let* ((by-track (seq-group-by (lambda (o) (plist-get o :track)) info)) (dir (expand-file-name "in-between" emacsconf-stream-asset-dir)) (template (expand-file-name "template.svg" dir))) (unless (file-directory-p dir) (make-directory dir t)) (mapc (lambda (track) (let (prev) (mapc (lambda (talk) (let ((dom (xml-parse-file template))) (mapc (lambda (entry) (let ((prefix (car entry))) (emacsconf-stream-svg-set-text dom (concat prefix "title") (plist-get (cdr entry) :title)) (emacsconf-stream-svg-set-text dom (concat prefix "speakers") (plist-get (cdr entry) :speakers)) (emacsconf-stream-svg-set-text dom (concat prefix "url") (and (cdr entry) (concat emacsconf-base-url (plist-get (cdr entry) :url)))) (emacsconf-stream-svg-set-text dom (concat prefix "qa") (pcase (plist-get (cdr entry) :q-and-a) ((rx "live") "Live Q&A after talk") ((rx "pad") "Etherpad") ((rx "IRC") "IRC Q&A after talk") (_ ""))))) (list (cons "previous-" prev) (cons "current-" talk))) (with-temp-file (expand-file-name (concat (plist-get talk :slug) ".svg") dir) (dom-print dom)) (shell-command (concat "inkscape --export-type=png -w 1280 -h 720 --export-background-opacity=0 " (shell-quote-argument (expand-file-name (concat (plist-get talk :slug) ".svg") dir))))) (setq prev talk)) (emacsconf-filter-talks (cdr track))))) by-track)))
(defun emacsconf-stream-svg-set-text (dom id text) "Update DOM to set the tspan in the element with ID to TEXT. If the element doesn't have a tspan child, use the element itself." (if (or (null text) (string= text "")) (let ((node (dom-by-id dom id))) (when node (dom-set-attribute node 'style "visibility: hidden") (dom-set-attribute (dom-child-by-tag node 'tspan) 'style "fill: none; stroke: none"))) (setq text (svg--encode-text text)) (let ((node (or (dom-child-by-tag (car (dom-by-id dom id)) 'tspan) (dom-by-id dom id)))) (cond ((null node) (error "Could not find node %s" id)) ; skip ((= (length node) 2) (nconc node (list text))) (t (setf (elt node 2) text))))))
From emacsconf-pad.el:
(defun emacsconf-pad-expand-intro (talk) "Make an intro for TALK." (cond ((null (plist-get talk :speakers)) (format "Next, we have \"%s\"." (plist-get talk :title))) ((plist-get talk :intro-note) (plist-get talk :intro-note)) (t (let ((pronoun (pcase (plist-get talk :pronouns) ((rx "she") "She") ((rx "\"ou\"" "Ou")) ((or 'nil "nil" (rx string-start "he") (rx "him")) "He") ((rx "they") "They") (_ (or (plist-get talk :pronouns) ""))))) (format "Next, we have \"%s\", by %s%s.%s" (plist-get talk :title) (replace-regexp-in-string ", \\([^,]+\\)$" ", and \\1" (plist-get talk :speakers)) (emacsconf-surround " (" (plist-get talk :pronunciation) ")" "") (pcase (plist-get talk :q-and-a) ((or 'nil "") "") ((rx "after") " You can ask questions via Etherpad and IRC. We'll send them to the speaker, and we'll post the answers on the talk page afterwards.") ((rx "live") (format " %s will answer questions via BigBlueButton. You can join using the URL from the talk page or ask questions through Etherpad or IRC." pronoun )) ((rx "pad") (format " %s will answer questions via Etherpad." pronoun )) ((rx "IRC") (format " %s will answer questions via IRC in the #%s channel." pronoun (plist-get talk :channel)))))))))
And from emacsconf-subed.el:
(defun emacsconf-subed-intro-subtitles () "Create the introduction as subtitles." (interactive) (subed-auto-insert) (let ((emacsconf-publishing-phase 'conference)) (mapc (lambda (sub) (apply #'subed-append-subtitle nil (cdr sub))) (seq-map-indexed (lambda (talk i) (list nil (* i 5000) (1- (* i 5000)) (format "#+OUTPUT: %s.webm\n[[file:%s]]\n%s" (plist-get talk :slug) (expand-file-name (concat (plist-get talk :slug) ".svg.png") (expand-file-name "in-between" emacsconf-stream-asset-dir)) (emacsconf-pad-expand-intro talk)))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))))
My first task was to figure out which videos needed to be trimmed to match the YouTube edits. First, I retrieved the video details using the API and the code that I added to emacsconf-extract.el.
(setq emacsconf-extract-youtube-api-video-details (emacsconf-extract-youtube-get-video-details emacsconf-extract-youtube-api-playlist-items))
Then I made a table comparing the file duration with the YouTube duration, showing rows only if the difference was more than 3 minutes.
(append '(("type" "slug" "file duration" "youtube duration" "diff")) (let ((threshold-secs (* 3 60))) ; don't sweat small differences (seq-mapcat (lambda (talk) (seq-keep (lambda (row) (when (plist-get talk (cadr row)) (let* ((video (emacsconf-extract-youtube-find-url-video-in-list (plist-get talk (cadr row)) emacsconf-extract-youtube-api-video-details)) (video-duration (if (and video (emacsconf-extract-youtube-duration-msecs video)) (/ (emacsconf-extract-youtube-duration-msecs video) 1000.0))) (file-duration (ceiling (/ (compile-media-get-file-duration-ms (emacsconf-talk-file talk (format "--%s.webm" (car row)))) 1000.0)))) (when (and video-duration (> (abs (- file-duration video-duration)) threshold-secs)) (list (car row) (plist-get talk :slug) (and file-duration (format-seconds "%h:%z%.2m:%.2s" file-duration)) (and video-duration (format-seconds "%h:%z%.2m:%.2s" video-duration)) (emacsconf-format-seconds (abs (- file-duration video-duration)))))))) '(("main" :youtube-url) ("answers" :qa-youtube-url)))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))
Then I got the commands to trim the videos.
(mapconcat (lambda (row) (let ((talk (emacsconf-resolve-talk (elt row 1)))) (format "ffmpeg -y -i %s--%s.webm -t %s -c copy %s--%s--trimmed.webm" (plist-get talk :file-prefix) (car row) (concat (elt row 3) ".000") (plist-get talk :file-prefix) (car row)))) (cdr to-trim) "\n"))
After quickly checking the results, I copied them over to the original videos, updated the video data in my conf.org, and republished the info pages in the wiki.
The time I spent on figuring out how to talk to the YouTube API feels like it's paying off.
]]>Update: fix total number of unique users; I flipped the assoc so that the car is the user ID and the cdr is the name
A few people have generously donated money to EmacsConf, so now we're thinking of how to use that money effectively to scale EmacsConf up or help people be happier.
One of the things I'd like to improve is our BigBlueButton web conferencing setup, since fiddling with the screen layout was a little annoying this year. We're using BigBlueButton 2.2, which was released in 2020. The current version is 2.7 and has a few improvements that I think would be very useful.
We'd love to use those improvements at the next EmacsConf, and they might be handy for the handful of other Emacs meetups who use our BigBlueButton setup from time to time. I think reducing the mental load from managing screen layouts might be an important step towards making it possible to have a third track.
The current BigBlueButton is a 6-core 3.4GHz virtual machine with 8 GB RAM. During EmacsConf 2023, the CPU load stayed at around 35%, with 4 GB memory used. It idles at 3% CPU and about 3 GB RAM. We have ssh access to an account with sudo, but no higher-level access in case that breaks or in case we mess up upgrading the underlying Ubuntu distribution too, which we should because it's reached its support end-of-life.
BigBlueButton's website recommends installing 2.7 on a clean, dedicated system instead of trying to do the upgrade in place. It requires a major version upgrade to at least Ubuntu 20.04, and it recommends 16 GB memory and 8 CPU cores.
System administration isn't my current cup of tea, and the other organizers might be busy.
Some choices we're thinking about are:
Commercial BigBlueButton hosts tend to charge based on the number of simultaneous users and the number of rooms.
It's been nice having one room per group of speakers because then we can e-mail speakers their personal URL for testing and checking in, the scripts can join the correct room automatically, we never have to worry about time, and all the recordings are split up. In previous years, we rotated among a set of five rooms, but then we needed to keep track of who was using which rooms. I think going with multiple rooms makes sense.
So it mostly comes down to the number of simultaneous users. I rsynced
/var/bbb/recording/raw
and cross-referenced each talk with its BBB
meeting using slugs I'd added to the meeting title, disambiguating
them as needed. Then I could use the following function from
emacsconf-extract.el:
(defun emacsconf-extract-bbb-report () (let* ((max 0) (participant-count 0) (meeting-count 0) (max-meetings 0) (max-participants 0) meeting-participants (meeting-events (sort (seq-mapcat (lambda (talk) (when (plist-get talk :bbb-meeting-id) (let ((dom (xml-parse-file (emacsconf-extract-bbb-raw-events-file-name talk))) participants talking meeting-events) (mapc (lambda (o) (pcase (dom-attr o 'eventname) ("ParticipantJoinEvent" (cl-pushnew (cons (dom-text (dom-by-tag o 'userId)) (dom-text (dom-by-tag o 'name))) participants) (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC))) (dom-attr o 'eventname)) meeting-events)) ("ParticipantLeftEvent" (when (string= (dom-attr o 'module) "PARTICIPANT") (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC))) (dom-attr o 'eventname)) meeting-events))) ("ParticipantTalkingEvent" (cl-pushnew (assoc-default (dom-text (dom-by-tag o 'participant)) participants) talking)) ((or "CreatePresentationPodEvent" "EndAndKickAllEvent") (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC))) (dom-attr o 'eventname)) meeting-events)))) (dom-search dom (lambda (o) (dom-attr o 'eventname)))) (cl-pushnew (list :slug (plist-get talk :slug) :participants participants :talking talking) meeting-participants) meeting-events))) (emacsconf-get-talk-info)) (lambda (a b) (< (car a) (car b)))))) (dolist (event meeting-events) (pcase (cdr event) ("CreatePresentationPodEvent" (cl-incf meeting-count) (when (> meeting-count max-meetings) (setq max-meetings meeting-count))) ("ParticipantJoinEvent" (cl-incf participant-count) (when (> participant-count max-participants) (setq max-participants participant-count))) ("ParticipantLeftEvent" (cl-decf participant-count)) ("EndAndKickAllEvent" (cl-decf meeting-count)))) `((,(length meeting-participants) "Number of meetings analyzed") (,max-participants "Max number of simultaneous users") (,max-meetings "Max number of simultaneous meetings") (,(apply 'max (mapcar (lambda (o) (length (plist-get o :participants))) meeting-participants)) "Max number of people in one meeting") (,(length (seq-uniq (seq-mapcat (lambda (o) (mapcar #'cdr (plist-get o :participants))) meeting-participants))) "Total unique users") (,(length (seq-uniq (seq-mapcat (lambda (o) (plist-get o :talking)) meeting-participants))) "Total unique talking"))))
31 | Number of meetings analyzed |
62 | Max number of simultaneous users |
6 | Max number of simultaneous meetings |
27 | Max number of people in one meeting |
84 | Total unique users |
36 | Total unique talking |
The number of simultaneous users is pretty manageable. Most people watch the stream, which we broadcast via Icecast, so those numbers aren't reflected here. I think we tended to have between 100-200 viewers on Icecast.
For that kind of usage, some hosting options are:
BigBlueButton hosting:
Host | Monthly | Concurrent users | Notes |
BiggerBlueButton | USD 40 | 150 | I'd need to check if we can have more than 10 created rooms if only at most 10 are used concurrently |
Web Hosting Zone | USD 49 | 100 | |
Myna Parrot | USD 60 | 75 | USD 150/month + USD 15 setup fee if we want to use our own URL |
BigBlueButton.host | USD 85 | 80 | |
BigBlueMeeting | USD 125 | 100 | |
BBB On Demand | 8 vCPU 32 GB RAM: USD 1.20/hour, USD 0.05/hour when stopped: USD 86 for 3 days | ||
BBB On Demand | 100 | USD 2.40/hour: USD 173 for 3 days |
Virtual private server: We'd need to set up and manage this ourselves. We could probably run it for one week before to give speakers time to do their tech-checks and one week after to give me time to pull the recordings. The other servers are on Linode, so it might make sense to keep it there too and manage it all in one place.
Type | Monthly | |
dedicated 8 GB 4-core | USD 72 | USD 0.108/hour, so USD 36 if we run it for two weeks |
dedicated CPU 16 GB 8-core | USD 144 | USD 0.216/hour, so USD 72 if we run it for two weeks |
It would be nice if we could just do the upgrade and get it back onto our current server (also, fixing up our current server with a proper SMTP setup so that it could send out things like password reminder emails), although the current BigBlueButton server was donated by a defunct organization so it might be a good idea to have a backup plan for it anyway.
It would also be nice to add it to our Ansible configuration so that we could install BigBlueButton that way, maybe based on ansible-role-bigbluebutton. But again, not my current cup of tea, so it will need to wait until someone can step up to do it or I get around to it.
The Free Software Foundation feels strongly about software as a service substitute. They're okay with virtual private servers, but I'm not sure how far their moral objection goes when it comes to using and paying for free/libre/opensource software as a service, like BigBlueButton. I'm personally okay with paying for services, especially if they're based on free software. Since EmacsConf is committed to using free software and not requiring people to use non-free software, that might be something the other organizers can weigh in on. If someone feels strongly enough about it, maybe they'll work on it. I think it can be hard enough for people to find the time for stuff they like, so if no one particularly likes doing this sort of stuff, I'm okay with scaling down or paying for something that's ready to go.
Anyway, at least we have the numbers for decisions!
]]>
(defun emacsconf-extract-youtube-publish-video-drafts-with-spookfox () "Look for drafts and publish them." (while (not (eq (spookfox-js-injection-eval-in-active-tab "document.querySelector('.edit-draft-button div') != null" t) :false)) (progn (spookfox-js-injection-eval-in-active-tab "document.querySelector('.edit-draft-button div').click()" t) (sleep-for 2) (spookfox-js-injection-eval-in-active-tab "document.querySelector('#step-title-3').click()" t) (when (spookfox-js-injection-eval-in-active-tab "document.querySelector('tp-yt-paper-radio-button[name=\"PUBLIC\"] #radioLabel').click()" t) (spookfox-js-injection-eval-in-active-tab "document.querySelector('#done-button').click()" t) (while (not (eq (spookfox-js-injection-eval-in-active-tab "document.querySelector('#close-button .label') == null" t) :false)) (sleep-for 1)) (spookfox-js-injection-eval-in-active-tab "document.querySelector('#close-button .label').click()" t) (sleep-for 1)))))
Another example of a hacky Spookfox workaround was publishing the
unlisted videos. I couldn't figure out how to properly authenticate
with the Toobnix (Peertube) API to change the visibility of videos.
Peertube uses AngularJS components in the front end, so using
.click()
on the input elements didn't seem to trigger anything. I
found out that I needed to use .dispatchEvent(new Event('input'))
to
tell the dropdown for the visibility to display the options. source
(defun emacsconf-extract-toobnix-publish-video-from-edit-page () "Messy hack to set a video to public and store the URL." (interactive) (spookfox-js-injection-eval-in-active-tab "document.querySelector('label[for=privacy]').scrollIntoView(); document.querySelector('label[for=privacy]').closest('.form-group').querySelector('input').dispatchEvent(new Event('input'));" t) (sit-for 1) (spookfox-js-injection-eval-in-active-tab "document.querySelector('span[title=\"Anyone can see this video\"]').click()" t) (sit-for 1) (spookfox-js-injection-eval-in-active-tab "document.querySelector('button.orange-button').click()" t)(sit-for 3) (emacsconf-extract-store-url) (shell-command "xdotool key Alt+Tab sleep 1 key Ctrl+w Alt+Tab"))
It's a little nicer using Spookfox to automate browser interactions than using xdotool, since I can get data out of it too. I could also have used Puppeteer from either Python or NodeJS, but it's nice staying with Emacs Lisp. Spookfox has some Javascript limitations (can't close windows, etc.), so I might still use bits of xdotool or Puppeteer to work around that. Still, it's nice to now have an idea of how to talk to AngularJS components.
]]>I used Emacs Lisp to generate a list of videos in the order I wanted. That Sunday closing remarks aren't actually in the playlists because they're combined with the Q&A for my session on how we run Emacsconf.
(defun emacsconf-extract-check-playlists () "Return a table for checking playlist order." (let ((pos 0)) (seq-mapcat (lambda (o) (delq nil (list (when (emacsconf-talk-file o "--main.webm") (cl-incf pos) (list pos (plist-get o :title) (org-link-make-string (plist-get o :youtube-url) "YouTube") (org-link-make-string (plist-get o :toobnix-url) "Toobnix"))) (when (emacsconf-talk-file o "--answers.webm") (cl-incf pos) (list pos (concat "Q&A: " (plist-get o :title)) (org-link-make-string (plist-get o :qa-youtube-url) "YouTube") (org-link-make-string (plist-get o :qa-toobnix-url) "Toobnix")))))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))
1 | An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp | YouTube | Toobnix |
2 | Authoring and presenting university courses with Emacs and a full libre software stack | YouTube | Toobnix |
3 | Q&A: Authoring and presenting university courses with Emacs and a full libre software stack | YouTube | Toobnix |
4 | Teaching computer and data science with literate programming tools | YouTube | Toobnix |
5 | Q&A: Teaching computer and data science with literate programming tools | YouTube | Toobnix |
6 | Who needs Excel? Managing your students qualifications with org-table | YouTube | Toobnix |
7 | one.el: the static site generator for Emacs Lisp Programmers | YouTube | Toobnix |
8 | Q&A: one.el: the static site generator for Emacs Lisp Programmers | YouTube | Toobnix |
9 | Emacs turbo-charges my writing | YouTube | Toobnix |
10 | Q&A: Emacs turbo-charges my writing | YouTube | Toobnix |
11 | Why Nabokov would use Org-Mode if he were writing today | YouTube | Toobnix |
12 | Q&A: Why Nabokov would use Org-Mode if he were writing today | YouTube | Toobnix |
13 | Collaborative data processing and documenting using org-babel | YouTube | Toobnix |
14 | How I play TTRPGs in Emacs | YouTube | Toobnix |
15 | Q&A: How I play TTRPGs in Emacs | YouTube | Toobnix |
16 | Org-Mode workflow: informal reference tracking | YouTube | Toobnix |
17 | (Un)entangling projects and repos | YouTube | Toobnix |
18 | Emacs development updates | YouTube | Toobnix |
19 | Emacs core development: how it works | YouTube | Toobnix |
20 | Top 10 ways Hyperbole amps up Emacs | YouTube | Toobnix |
21 | Using Koutline for stream of thought journaling | YouTube | Toobnix |
22 | Parallel text replacement | YouTube | Toobnix |
23 | Q&A: Parallel text replacement | YouTube | Toobnix |
24 | Eat and Eat powered Eshell, fast featureful terminal inside Emacs | YouTube | Toobnix |
25 | The browser in a buffer | YouTube | Toobnix |
26 | Speedcubing in Emacs | YouTube | Toobnix |
27 | Emacs MultiMedia System (EMMS) | YouTube | Toobnix |
28 | Q&A: Emacs MultiMedia System (EMMS) | YouTube | Toobnix |
29 | Programming with steno | YouTube | Toobnix |
30 | Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs) | YouTube | Toobnix |
31 | Q&A: Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs) | YouTube | Toobnix |
32 | Emacs saves the Web (maybe) | YouTube | Toobnix |
33 | Q&A: Emacs saves the Web (maybe) | YouTube | Toobnix |
34 | Sharing Emacs is Caring Emacs: Emacs education and why I embraced video | YouTube | Toobnix |
35 | Q&A: Sharing Emacs is Caring Emacs: Emacs education and why I embraced video | YouTube | Toobnix |
36 | MatplotLLM, iterative natural language data visualization in org-babel | YouTube | Toobnix |
37 | Enhancing productivity with voice computing | YouTube | Toobnix |
38 | Q&A: Enhancing productivity with voice computing | YouTube | Toobnix |
39 | LLM clients in Emacs, functionality and standardization | YouTube | Toobnix |
40 | Q&A: LLM clients in Emacs, functionality and standardization | YouTube | Toobnix |
41 | Improving compiler diagnostics with overlays | YouTube | Toobnix |
42 | Q&A: Improving compiler diagnostics with overlays | YouTube | Toobnix |
43 | Editor Integrated REPL Driven Development for all languages | YouTube | Toobnix |
44 | REPLs in strange places: Lua, LaTeX, LPeg, LPegRex, TikZ | YouTube | Toobnix |
45 | Literate Documentation with Emacs and Org Mode | YouTube | Toobnix |
46 | Q&A: Literate Documentation with Emacs and Org Mode | YouTube | Toobnix |
47 | Windows into Freedom | YouTube | Toobnix |
48 | Bringing joy to Scheme programming | YouTube | Toobnix |
49 | Q&A: Bringing joy to Scheme programming | YouTube | Toobnix |
50 | GNU Emacs: A World of Possibilities | YouTube | Toobnix |
51 | Q&A: GNU Emacs: A World of Possibilities | YouTube | Toobnix |
52 | A modern Emacs look-and-feel without pain | YouTube | Toobnix |
53 | The Emacsen family, the design of an Emacs and the importance of Lisp | YouTube | Toobnix |
54 | Q&A: The Emacsen family, the design of an Emacs and the importance of Lisp | YouTube | Toobnix |
55 | emacs-gc-stats: Does garbage collection actually slow down Emacs? | YouTube | Toobnix |
56 | Q&A: emacs-gc-stats: Does garbage collection actually slow down Emacs? | YouTube | Toobnix |
57 | hyperdrive.el: Peer-to-peer filesystem in Emacs | YouTube | Toobnix |
58 | Q&A: hyperdrive.el: Peer-to-peer filesystem in Emacs | YouTube | Toobnix |
59 | Writing a language server in OCaml for Emacs, fun, and profit | YouTube | Toobnix |
60 | Q&A: Writing a language server in OCaml for Emacs, fun, and profit | YouTube | Toobnix |
61 | What I learned by writing test cases for GNU Hyperbole | YouTube | Toobnix |
62 | Q&A: What I learned by writing test cases for GNU Hyperbole | YouTube | Toobnix |
63 | EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference | YouTube | Toobnix |
64 | Q&A: EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference | YouTube | Toobnix |
65 | Saturday opening remarks | YouTube | Toobnix |
66 | Saturday closing remarks | YouTube | Toobnix |
67 | Sunday opening remarks | YouTube | Toobnix |
68 | Sunday closing remarks | YouTube | Toobnix |
I bulk-added the Youtube videos to the playlist. The videos were not in order because I uploaded some late submissions and forgotten videos, which then got added to the end of the list.
I tried using the API to sort the playlist. This got it most of the way there, and then I sorted the rest by hand.
(defun emacsconf-extract-youtube-api-sort-playlist (&optional dry-run-only) "Try to roughly sort the playlist." (interactive) (setq emacsconf-extract-youtube-api-playlist (seq-find (lambda (o) (let-alist o (string= .snippet.title (concat emacsconf-name " " emacsconf-year)))) (assoc-default 'items emacsconf-extract-youtube-api-playlists))) (setq emacsconf-extract-youtube-api-playlist-items (emacsconf-extract-youtube-api-paginated-request (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=100&playlistId=" (url-hexify-string (assoc-default 'id emacsconf-extract-youtube-api-playlist))))) (let* ((playlist-info emacsconf-extract-youtube-api-playlists) (playlist-items emacsconf-extract-youtube-api-playlist-items) (info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))) (slugs (seq-map (lambda (o) (plist-get o :slug)) info)) (position (1- (length playlist-items))) result) ;; sort items (mapc (lambda (talk) (when (plist-get talk :qa-youtube-id) ;; move the q & a (let ((video-object (emacsconf-extract-youtube-find-url-video-in-list (plist-get talk :qa-youtube-url) playlist-items))) (let-alist video-object (cond ((null video-object) (message "Could not find video for %s" (plist-get talk :slug))) ;; not in the right position, try to move it ((< .snippet.position position) (let ((video-id .id) (playlist-id .snippet.playlistId) (resource-id .snippet.resourceId)) (message "Trying to move %s Q&A to %d from %d" (plist-get talk :slug) position .snippet.position) (add-to-list 'result (list (plist-get talk :slug) "answers" .snippet.position position)) (unless dry-run-only (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) ("Accept" . "application/json") ("Content-Type" . "application/json")) :body (json-encode `((id . ,video-id) (snippet (playlistId . ,playlist-id) (resourceId . ,resource-id) (position . ,position)))))))))) (setq position (1- position)))) ;; move the talk if needed (let ((video-object (emacsconf-extract-youtube-find-url-video-in-list (plist-get talk :youtube-url) playlist-items))) (let-alist video-object (cond ((null video-object) (message "Could not find video for %s" (plist-get talk :slug))) ;; not in the right position, try to move it ((< .snippet.position position) (let ((video-id .id) (playlist-id .snippet.playlistId) (resource-id .snippet.resourceId)) (message "Trying to move %s to %d from %d" (plist-get talk :slug) position .snippet.position) (add-to-list 'result (list (plist-get talk :slug) "main" .snippet.position position)) (unless dry-run-only (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) ("Accept" . "application/json") ("Content-Type" . "application/json")) :body (json-encode `((id . ,video-id) (snippet (playlistId . ,playlist-id) (resourceId . ,resource-id) (position . ,position)))))) )))) (setq position (1- position)))) (nreverse info)) result))
I needed to sort some of the videos manually. Trying to scroll by dragging items to the top of the currently-displayed section of the list was slow, and dropping the item near the top of the list so that I could pick it up again after paging up was a little disorienting. Fortunately, keyboard scrolling with page-up and page-down worked even while dragging an item, so that was what I ended up doing: select the item and then page-up while dragging.
YouTube doesn't display numbers for the playlist positions, but this will add them. The numbers don't dynamically update when the list is reordered, so I just re-ran the code after moving things around.
(defun emacsconf-extract-youtube-spookfox-add-playlist-numbers () "Number the playlist for easier checking. Related: `emacsconf-extract-check-playlists'." (interactive) (spookfox-js-injection-eval-in-active-tab "[...document.querySelectorAll('ytd-playlist-video-renderer')].forEach((o, i) => { o.querySelector('.number')?.remove(); let div = document.createElement('div'); div.classList.add('number'); div.textContent = i; o.prepend(div) }))" t))
In retrospect, I could probably have just cleared the playlist and then added the videos using the in the right order instead of fiddling with inserting things.
Toobnix (Peertube) doesn't seem to have a way to bulk-add videos to a playlist (or even to bulk-set their visibility). I started trying to figure out how to use the API, but I got stuck because my token didn't seem to let me access unlisted videos or do other things that required proper authentication. Anyway, I came up with this messy hack to open the videos in sequence and add them to the playlist using Spookfox.
(defun emacsconf-extract-toobnix-set-up-playlist () (interactive) (mapcar (lambda (o) (when (plist-get o :toobnix-url) (browse-url (plist-get o :toobnix-url)) (read-key "press a key when page is loaded") (spookfox-js-injection-eval-in-active-tab "document.querySelector('.action-button-save').click()" t) (spookfox-js-injection-eval-in-active-tab "document.querySelector('my-peertube-checkbox').click()" t) (read-key "press a key when saved to playlist")) (when (plist-get o :qa-toobnix-url) (browse-url (plist-get o :qa-toobnix-url)) (read-key "press a key when page is loaded") (spookfox-js-injection-eval-in-active-tab "document.querySelector('.action-button-save').click()" t) (spookfox-js-injection-eval-in-active-tab "document.querySelector('my-peertube-checkbox').click()" t) (read-key "press a key when saved to playlist"))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
Maybe next year, I might be able to figure out how to use the APIs to do this stuff automatically.
This code is in emacsconf-extract.el.
I can upload to Toobnix and set titles and descriptions using the peertube-cli tool. I tried a Python script for uploading to YouTube, but it was a bit annoying due to quota restrictions. Instead, I uploaded the videos by dragging and dropping them into YouTube Studio. This allowed me to upload 15 at a time.
The videos on YouTube had just the filenames. I wanted to rename the videos and set the descriptions. In 2022, I used xdotool, simulating mouse clicks and pasting in text for larger text blocks.
(defun my-xdotool-insert-mouse-location (interactive) (let ((pos (shell-command-to-string "xdotool getmouselocation"))) (when (string-match "x:\\([0-9]+\\) y:\\([0-9]+\\)" pos) (insert (format "(shell-command \"xdotool mousemove %s %s click 1\")\n" (match-string 1 pos) (match-string 2 pos)))))) (setq list (seq-filter (lambda (o) (and (file-exists-p (expand-file-name (concat (plist-get o :video-slug) "--final.webm") emacsconf-cache-dir)) (null (plist-get o :youtube-url)))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))) (while list (progn (shell-command "xdotool mousemove 707 812 click 1 sleep 2") (setq talk (pop list)) ;; click create (shell-command "xdotool mousemove 843 187 click 1 sleep 1") ;; video (shell-command "xdotool mousemove 833 217 click 1 sleep 1") ;; select files (shell-command (concat "xdotool mousemove 491 760 click 1 sleep 4 type " (shell-quote-argument (concat (plist-get talk :video-slug) "--final.webm")))) ;; open (shell-command "xdotool mousemove 1318 847 click 1 sleep 5") (kill-new (concat emacsconf-name " " emacsconf-year ": " (plist-get talk :title) " - " (plist-get talk :speakers-with-pronouns))) (shell-command "xdotool sleep 1 mousemove 331 440 click :1 key Ctrl+a Delete sleep 1 key Ctrl+Shift+v sleep 2") (kill-new (emacsconf-publish-video-description talk t)) (shell-command "xdotool mousemove 474 632 click 1 sleep 1 key Ctrl+a sleep 1 key Delete sleep 1 key Ctrl+Shift+v")) (read-string "Press a key once you've pasted in the description") ;; next (when (emacsconf-captions-edited-p (expand-file-name (concat (plist-get talk :video-slug) "--main.vtt") emacsconf-cache-dir)) (shell-command "xdotool mousemove 352 285 click 1 sleep 1") ;; add captions (shell-command "xdotool mousemove 877 474 click 1 sleep 3") (shell-command "xdotool mousemove 165 408 click 1 sleep 1") (shell-command "xdotool mousemove 633 740 click 1 sleep 2") (shell-command (concat "xdotool mousemove 914 755 click 1 sleep 4 type " (shell-quote-argument (concat (plist-get talk :video-slug) "--main.vtt")))) (read-string "Press a key once you've loaded the VTT") (shell-command "xdotool mousemove 910 1037 sleep 1 click 1 sleep 4") ;; done (shell-command "xdotool mousemove 890 297 click 1 sleep 3") ) (progn ;; visibility (shell-command "xdotool mousemove 810 303 click 1 sleep 2") ;; public (shell-command "xdotool mousemove 119 614 click 1 sleep 2") ;; copy (shell-command "xdotool mousemove 882 669 click 1 sleep 1") ;; done (shell-command "xdotool mousemove 908 1089 click 1 sleep 5 key Alt+Tab") (emacsconf-with-talk-heading talk (org-entry-put (point) "YOUTUBE_URL" (read-string "URL: ")) )) )
Using xdotool wasn't very elegant, since I needed to figure out the coordinates for each click. I tried using Spookfox to control Mozilla Firefox from Emacs, but Youtube's editing interface didn't seem to have any textboxes that I could set. I decided to use EmacsConf 2023 as an excuse to learn how to talk to the Youtube Data API, which required figuring out OAuth. Even though it was easy to find examples in Python and NodeJS, I wanted to see if I could stick with using Emacs Lisp so that I could add the code to the emacsconf-el repository.
After a quick search, I picked url-http-oauth as the library that I'd
try first. I used the url-http-oauth-demo.el included in the package
to figure out what to set for the YouTube Data API. I wrote a function
to make getting the redirect URL easier
(emacsconf-extract-oauth-browse-and-prompt
). Once I authenticated
successfully, I explored using alphapapa's plz library. It can handle
finding the JSON object and parsing it out for me. With it, I updated
videos to include titles and descriptions from my Emacs code, and I
copied the video IDs into my Org properties.
;;; YouTube ;; When the token needs refreshing, delete the associated lines from ;; ~/.authinfo This code just sets the title and description. Still ;; need to figure out how to properly set the license, visibility, ;; recording date, and captions. ;; ;; To avoid being prompted for the client secret, it's helpful to have a line in ~/.authinfo or ~/.authinfo.gpg with ;; machine https://oauth2.googleapis.com/token username CLIENT_ID password CLIENT_SECRET (defvar emacsconf-extract-google-client-identifier nil) (defvar emacsconf-extract-youtube-api-channels nil) (defvar emacsconf-extract-youtube-api-categories nil) (defun emacsconf-extract-oauth-browse-and-prompt (url) "Open URL and wait for the redirected code URL." (browse-url url) (read-from-minibuffer "Paste the redirected code URL: ")) (defun emacsconf-extract-youtube-api-setup () (interactive) (require 'plz) (require 'url-http-oauth) (when (getenv "GOOGLE_APPLICATION_CREDENTIALS") (let-alist (json-read-file (getenv "GOOGLE_APPLICATION_CREDENTIALS")) (setq emacsconf-extract-google-client-identifier .web.client_id))) (unless (url-http-oauth-interposed-p "https://youtube.googleapis.com/youtube/v3/") (url-http-oauth-interpose `(("client-identifier" . ,emacsconf-extract-google-client-identifier) ("resource-url" . "https://youtube.googleapis.com/youtube/v3/") ("authorization-code-function" . emacsconf-extract-oauth-browse-and-prompt) ("authorization-endpoint" . "https://accounts.google.com/o/oauth2/v2/auth") ("authorization-extra-arguments" . (("redirect_uri" . "http://localhost:8080"))) ("access-token-endpoint" . "https://oauth2.googleapis.com/token") ("scope" . "https://www.googleapis.com/auth/youtube") ("client-secret-method" . prompt)))) (setq emacsconf-extract-youtube-api-channels (plz 'get "https://youtube.googleapis.com/youtube/v3/channels?part=contentDetails&mine=true" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) :as #'json-read)) (setq emacsconf-extract-youtube-api-categories (plz 'get "https://youtube.googleapis.com/youtube/v3/videoCategories?part=snippet®ionCode=CA" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) :as #'json-read)) (setq emacsconf-extract-youtube-api-videos (plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" (url-hexify-string (let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0) .contentDetails.relatedPlaylists.uploads) )) :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) :as #'json-read))) (defvar emacsconf-extract-youtube-tags '("emacs" "emacsconf")) (defun emacsconf-extract-youtube-object (video-id talk &optional privacy-status) "Format the video object for VIDEO-ID using TALK details." (setq privacy-status (or privacy-status "unlisted")) (let ((properties (emacsconf-publish-talk-video-properties talk 'youtube))) `((id . ,video-id) (kind . "youtube#video") (snippet (categoryId . "28") (title . ,(plist-get properties :title)) (tags . ,emacsconf-extract-youtube-tags) (description . ,(plist-get properties :description)) ;; Even though I set recordingDetails and status, it doesn't seem to stick. ;; I'll leave this in here in case someone else can figure it out. (recordingDetails (recordingDate . ,(format-time-string "%Y-%m-%dT%TZ" (plist-get talk :start-time) t)))) (status (privacyStatus . "unlisted") (license . "creativeCommon"))))) (defun emacsconf-extract-youtube-api-update-video (video-object) "Update VIDEO-OBJECT." (let-alist video-object (let* ((slug (cond ;; not yet renamed ((string-match (rx (literal emacsconf-id) " " (literal emacsconf-year) " " (group (1+ (or (syntax word) "-"))) " ") .snippet.title) (match-string 1 .snippet.title)) ;; renamed, match the description instead ((string-match (rx (literal emacsconf-base-url) (literal emacsconf-year) "/talks/" (group (1+ (or (syntax word) "-")))) .snippet.description) (match-string 1 .snippet.description)) ;; can't find, prompt (t (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year)) .snippet.title) (completing-read (format "Slug for %s: " .snippet.title) (seq-map (lambda (o) (plist-get o :slug)) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))))) (video-id .snippet.resourceId.videoId) (id .id) result) (when slug ;; set the YOUTUBE_URL property (emacsconf-with-talk-heading slug (org-entry-put (point) "YOUTUBE_URL" (concat "https://www.youtube.com/watch?v=" video-id)) (org-entry-put (point) "YOUTUBE_ID" id)) (plz 'put "https://www.googleapis.com/youtube/v3/videos?part=snippet,recordingDetails,status" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) ("Accept" . "application/json") ("Content-Type" . "application/json")) :body (json-encode (emacsconf-extract-youtube-object video-id (emacsconf-resolve-talk slug)))))))) (defun emacsconf-extract-youtube-rename-videos (&optional videos) "Rename videos and set the YOUTUBE_URL property in the Org heading." (let ((info (emacsconf-get-talk-info))) (mapc (lambda (video) (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year))) (emacsconf-extract-youtube-api-update-video video))) (assoc-default 'items (or videos emacsconf-extract-youtube-api-videos))))) (provide 'emacsconf-extract)
I haven't quite figured out how to set status
and recordingDetails
properly. The code sets them, but they don't stick. That's okay. I
think I can set those as a batch operation. It looks like I need to
change visibility one by one, though, which might be a good
opportunity to check the end of the video for anything that needs to
be trimmed off.
I also want to figure out how to upload captions. I'm not entirely
sure how to do multipart form data yet with the url
library or
plz
. It might be nice to someday set up an HTTP server so that Emacs
can handle OAuth redirects itself. I'll save that for another blog
post and share my notes for now.
This code is in emacsconf-extract.el.
]]>Here's where we are with regard to talk status:
Waiting for 26 talks (~550 minutes) out of 42 total. Talks received so far:
TO_ASSIGN
(waiting for captioning volunteers) - 8 talk(s), 150
minutes: adventure (05:58), matplotllm (09:34), teaching (19:27),
nabokov (09:51), collab (19:16), doc (42:45), scheme (21:01),
emacsen (18:28)TO_CAPTION
- 2 talk(s), 21 minutes: eval (09:35), mentor (10:44)TO_STREAM
- 6 talk(s), 124 minutes: llm (20:26), writing (08:53),
ref (15:04), emacsconf (15:05), world (22:20), emms (38:38)Speakers have been really nice about keeping in touch, so I'm not too stressed about gaps in the schedule. Captioning volunteers have been chugging through the talks and OpenAI Whisper's gotten a bit better at spelling things, so that's terrific too. It's so exciting!
zaeph and bandali will probably host the general track and the development track respectively. They've done it for a number of years now, so it'll probably be fine even if we don't have a dry run all together since they've got limited availability. (And we can take on new volunteers if people want to help read questions!)
My stress level is pretty manageable at this point. I can even spend evenings playing video games with the kiddo and weekends going on little bike adventures, so that's awesome. I'm still a little worried about tech hiccups, but we'll probably be able to figure things out.
Next steps are:
It's happening!
]]>