Skip to content

Commit

Permalink
Generate FILE.narrated.sozi.html syncing presentation with an audio t…
Browse files Browse the repository at this point in the history
…rack

This commit allows a Sozi presentation to be played in sync with a
recorded narrative.flac audio file without requiring the entire
presentation to be converted to a video format.
This format has two benefits:
1. Presentation quality is preserved in the html+svg+js format,
2. Exported file size is kept minimal.
  • Loading branch information
momeni committed Aug 3, 2023
1 parent 3385887 commit 808b439
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 2 deletions.
3 changes: 3 additions & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ function makeBrowserifyTask(name) {

const playerBrowserifyTask = parallel(
makeBrowserifyTask("player"),
makeBrowserifyTask("narrated"),
makeBrowserifyTask("presenter")
);

Expand Down Expand Up @@ -266,11 +267,13 @@ function makeTemplateTask(target, tpl) {

const electronTemplatesTask = parallel(
makeTemplateTask("electron", "player"),
makeTemplateTask("electron", "narrated"),
makeTemplateTask("electron", "presenter")
);

const browserTemplatesTask = parallel(
makeTemplateTask("browser", "player"),
makeTemplateTask("browser", "narrated"),
makeTemplateTask("browser", "presenter")
);

Expand Down
36 changes: 34 additions & 2 deletions src/js/Storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,11 @@ export class Storage {
const svgName = this.backend.getName(this.svgFileDescriptor);
const htmlFileName = replaceFileExtWith(svgName, ".sozi.html");
const presenterFileName = replaceFileExtWith(svgName, "-presenter.sozi.html");
const narratedFileName = replaceFileExtWith(svgName, ".narrated.sozi.html");
// TODO Save only if SVG is more recent than HTML.
this.createHTMLFile(htmlFileName, location);
this.createPresenterHTMLFile(presenterFileName, location, htmlFileName);
this.createNarratedHTMLFile(narratedFileName, location, htmlFileName);
}

/** Create the presentation HTML file if it does not exist.
Expand Down Expand Up @@ -312,10 +314,26 @@ export class Storage {
async createPresenterHTMLFile(name, location, htmlFileName) {
try {
const fileDescriptor = await this.backend.find(name, location);
this.backend.save(fileDescriptor, this.exportPresenterHTML(htmlFileName));
await this.backend.save(fileDescriptor, this.exportPresenterHTML(htmlFileName));
}
catch (err) {
this.backend.create(name, location, "text/html", this.exportPresenterHTML(htmlFileName));
await this.backend.create(name, location, "text/html", this.exportPresenterHTML(htmlFileName));
}
}

/** Create the narrated HTML file if it does not exist.
*
* @param {string} name - The name of the HTML file to create.
* @param {any} location - The location of the file (backend-dependent).
* @param {string} htmlFileName - The name of the presentation HTML file.
*/
async createNarratedHTMLFile(name, location, htmlFileName) {
try {
const fileDescriptor = await this.backend.find(name, location);
await this.backend.save(fileDescriptor, this.exportNarratedHTML(htmlFileName));
}
catch (err) {
await this.backend.create(name, location, "text/html", this.exportNarratedHTML(htmlFileName));
}
}

Expand Down Expand Up @@ -403,6 +421,20 @@ export class Storage {
});
}

/** Generate the content of the narrated HTML file.
*
* The result is derived from the `narrated.html` template.
*
* @param {string} htmlFileName - The name of the presentation HTML file to play.
* @returns {string} - An HTML document content, as text.
*/
exportNarratedHTML(htmlFileName) {
return nunjucks.render("narrated.html", {
pres: this.presentation,
soziHtml: htmlFileName
});
}

/** Get the path of a file relative to the location of the current SVG file.
*
* @param {string} filePath - The path of a file.
Expand Down
94 changes: 94 additions & 0 deletions src/js/narrated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@

window.addEventListener("load", () => {
const narrative = document.getElementById("narrative")

const timeToSlide = (() => {
const timeToSlide = new Map()
let prevSlide = 0
for (const x of narrative.dataset.timeToSlide.split(",")) {
const [time, slide] = x.split(":")
prevSlide = parseInt((slide === undefined) ? prevSlide+1 : slide)
timeToSlide.set(parseInt(time), prevSlide - 1)
}
return timeToSlide
})()
const slideToTimeIntervals = (() => {
const slideToTimeIntervals = new Map()
const process = (start, end, slide) => {
let timeIntervals = slideToTimeIntervals.get(slide)
if (timeIntervals === undefined) {
timeIntervals = []
slideToTimeIntervals.set(slide, timeIntervals)
}
timeIntervals.push([start, end])
}
let start, slide
for (const [end, nextSlide] of timeToSlide) {
if (start === undefined) {
start = end
slide = nextSlide
continue
}
process(start, end, slide)
start = end
slide = nextSlide
}
if (start !== undefined) {
process(start, Number.POSITIVE_INFINITY, slide)
}
return slideToTimeIntervals
})()

let currentSlide = 0

const updateAudio = (newSlide) => {
currentSlide = newSlide
const timeIntervals = slideToTimeIntervals.get(newSlide)
if (timeIntervals === undefined) {
narrative.pause()
return
}
const t = narrative.currentTime
let nearestTime
for (const [start, end] of timeIntervals) {
if (start <= t && t < end) {
return
}
if (nearestTime === undefined || start <= t) {
nearestTime = start
}
}
narrative.currentTime = nearestTime
}
const updateSlide = (target) => {
const t = narrative.currentTime
let targetSlide = 0
for (const [time, slide] of timeToSlide) {
if (time <= t) {
targetSlide = slide
}
}
if (targetSlide !== currentSlide) {
currentSlide = targetSlide
target.postMessage({name:"moveToFrame", args: [currentSlide]},"*")
}
}
window.addEventListener("message", (e) => {
switch(e.data.name) {
case "loaded":
const target = e.source
target.postMessage({name:"notifyOnFrameChange"},"*")
const frame = document.querySelector("iframe")
setInterval(() => {
frame.focus()
}, 1000)
narrative.ontimeupdate = () => {
updateSlide(target)
}
break
case "frameChange":
updateAudio(e.data.index)
break
}
})
}, false)
47 changes: 47 additions & 0 deletions src/templates/narrated.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. #}

{% raw %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ pres.title }}</title>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
}
.presentation-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
iframe {
border: none;
}
audio {
position: fixed;
right: 0;
bottom: 0;
width: 25%;
}
</style>
</head>
<body>
<iframe class="presentation-container" src="{{ soziHtml }}">
The Sozi presentation should play here.
</iframe>
<audio id="narrative" controls data-time-to-slide="0:1,1:3,2:6">
<source src="narrative.flac" type="audio/flac" preload="auto">
Audio HTML5 tag is not supported!
</audio>
{% endraw %}
<script>{{'{% raw %}'}}{{ js|safe }}{{'{% endraw %}'}}</script>
</body>
</html>

0 comments on commit 808b439

Please sign in to comment.