Play the storyboard: frames and narration, in order, at the top

Reviewing meant scrolling to a shot, pressing play, listening, scrolling to
the next - which destroys exactly the flow a trailer is made of, so faults of
sequence were the hardest to notice and the ones we kept shipping.

This plays the whole thing: opening frame, then closing frame, while the
shot's lines are spoken, then on to the next. Stop, or jump to any shot to
leave a note. It is the trailer before it is filmed, for cents of speech
rather than dollars of video.
This commit is contained in:
gator 2026-09-17 14:07:50 +00:00
parent 70bf7758e3
commit fc858729f3

View file

@ -432,6 +432,107 @@
return (Date.now() - t) < 15 * 60 * 1000;
}
// ---- the storyboard player -------------------------------------------
// One call fetches the whole reel; after that nothing touches the network,
// which is what makes stop and jump-to instant.
var REEL = null, RAT = -1, RTIMER = null;
var reelAudio = new Audio();
function reelHtml() {
var st = String(PKG.reel_state || "");
var shots = (PKG.shots || []);
if (!shots.length) return "";
var running = st && st !== "ready" && !/^failed/.test(st);
var out = '<div class="reel"><h4>Watch the storyboard</h4>'
+ '<p class="lede">The frames in order with the lines spoken over them — '
+ 'the trailer before it is filmed. Sequence problems are obvious here '
+ 'and nearly invisible in the script below.</p>';
if (running)
out += '<p class="busy">Speaking every shot… ' + esc(st) + '</p>';
else if (/^failed/.test(st))
out += '<p class="cont">' + esc(st) + '</p>';
if (!REEL) {
out += '<div class="tools" style="justify-content:center">'
+ '<button type="button" class="btn primary" data-reelgo="1"'
+ (running ? ' disabled' : '') + '>'
+ (st === "ready" ? "Play the storyboard" : "Prepare and play")
+ '</button></div></div>';
return out;
}
var s = REEL[RAT] || REEL[0] || {};
var lines = (s.lines || []).map(function (l) {
return '<div><span class="who">' + esc(l.who || "")
+ (l.lip_sync ? "" : " (v.o.)") + '</span>' + esc(l.text || "") + '</div>';
}).join("") || '<div class="w">no dialogue in this shot</div>';
out += '<div class="reelframes">'
+ '<figure><img id="rfa" class="on" src="' + esc(s.opening_url || "")
+ '" alt=""><figcaption>opens on</figcaption></figure>'
+ '<figure><img id="rfb" src="' + esc(s.closing_url || "")
+ '" alt=""><figcaption>ends on</figcaption></figure></div>'
+ '<div class="reelnow"><div class="t">' + esc(s.id) + ' · '
+ esc(s.title || "") + '</div>'
+ '<div class="w">' + esc(s.setting || "")
+ (s.scene ? ' · ' + esc(s.scene) : "")
+ (s.day ? ' · ' + esc(s.day) : "") + '</div>'
+ '<div class="said">' + lines + '</div></div>'
+ '<div class="reelprog"><i id="rprog"></i></div>'
+ '<div class="reelbar">'
+ '<button type="button" class="btn" data-reelstep="-1">◀ back</button>'
+ '<button type="button" class="btn primary" data-reeltoggle="1">'
+ (reelAudio.paused ? "Play" : "Pause") + '</button>'
+ '<button type="button" class="btn" data-reelstep="1">next ▶</button>'
+ '<button type="button" class="btn" data-reelstop="1">Stop</button>'
+ '<button type="button" class="btn" data-reeljump="' + esc(s.id)
+ '">Jump to this shot</button>'
+ '<span class="pos">' + (RAT + 1) + ' of ' + REEL.length + '</span>'
+ '</div></div>';
return out;
}
function reelPaint() {
var host = document.getElementById("reelhost");
if (host) host.innerHTML = reelHtml();
}
// The two frames split the shot's running time: the opening frame holds for
// the first half, the closing frame for the second. It is a storyboard, not
// an animation, but it reads as movement and it marks where the shot lands.
function reelShow(i) {
if (!REEL || i < 0 || i >= REEL.length) { reelStop(); return; }
RAT = i;
reelPaint();
var s = REEL[i];
var dur = Math.max(Number(s.seconds) || 0, Number(s.edit_seconds) || 0, 2.5);
if (s.audio_url) { reelAudio.src = s.audio_url; reelAudio.play().catch(function(){}); }
else reelAudio.pause();
var t0 = Date.now(), swapped = false;
clearInterval(RTIMER);
RTIMER = setInterval(function () {
var gone = (Date.now() - t0) / 1000;
var bar = document.getElementById("rprog");
if (bar) bar.style.width = Math.min(100, 100 * gone / dur) + "%";
if (!swapped && gone > dur / 2) {
swapped = true;
var a = document.getElementById("rfa"), b = document.getElementById("rfb");
if (a && b && REEL[RAT].closing_url) { a.classList.remove("on"); b.classList.add("on"); }
}
if (gone >= dur) reelShow(RAT + 1);
}, 120);
}
function reelStop() {
clearInterval(RTIMER); RTIMER = null;
reelAudio.pause();
REEL = null; RAT = -1;
openTab(CUR);
}
// A cut is an ordering, not a render. Playing one is free, so all of them
// can be looked at before anything is joined into a file.
function cutsHtml(meta) {
@ -573,6 +674,7 @@
shots.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); });
return head + probs + '<div class="bar">' + barHtml(meta) + '</div>'
+ '<div id="reelhost">' + reelHtml() + '</div>'
+ spineHtml() + cutsHtml(meta) + reviewsHtml(meta) + castHtml + body;
}