trailer.html: draw the storyboard, and show the cast sheets

The draw button is live and priced from what that writer actually wrote -
three sheets a character plus an opening and a closing frame a shot - so a
14-shot trailer costs $3.33 and a 30-shot one costs more, rather than both
being quoted an average.

Cast sheets render as head/front/side, because they are the thing that stops a
face drifting between shots and the author should be able to reject a face
before seventy pictures are drawn from it.

Progress is per picture rather than a spinner: drawing takes minutes and a
spinner that might mean "working" or "hung" is worse than a count.
This commit is contained in:
gator 2026-09-17 02:23:59 +00:00
parent d1c9320966
commit c6788afba5

View file

@ -17,11 +17,14 @@
.head h2{margin:0;font-size:22px} .head h2{margin:0;font-size:22px}
.tag{color:var(--muted);font-style:italic} .tag{color:var(--muted);font-style:italic}
.facts{color:var(--muted);font-size:13px;margin:2px 0 18px} .facts{color:var(--muted);font-size:13px;margin:2px 0 18px}
.cast{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 22px} .cast{display:flex;flex-wrap:wrap;gap:14px;margin:0 0 22px}
.ch{border:1px solid var(--line);border-radius:10px;padding:9px 12px;font-size:13px;max-width:330px} .ch{border:1px solid var(--line);border-radius:10px;padding:10px;font-size:13px;width:250px}
.ch b{display:block} .ch b{display:block}
.ch .vo{color:var(--muted);font-size:12px} .ch .vo{color:var(--muted);font-size:12px}
.ch .ap{color:var(--muted);font-size:12px;margin-top:3px} .ch .ap{color:var(--muted);font-size:12px;margin-top:4px}
.sheets{display:flex;gap:5px;margin-bottom:8px}
.sheets img{width:33%;border-radius:6px;border:1px solid var(--line);display:block;
aspect-ratio:16/9;object-fit:cover}
.shot{border-top:1px solid var(--line);padding:16px 0;display:flex;gap:16px} .shot{border-top:1px solid var(--line);padding:16px 0;display:flex;gap:16px}
.shot .num{width:34px;flex:none;color:var(--muted);font-variant-numeric:tabular-nums} .shot .num{width:34px;flex:none;color:var(--muted);font-variant-numeric:tabular-nums}
.shot .body{flex:1;min-width:0} .shot .body{flex:1;min-width:0}
@ -83,36 +86,51 @@
if (!runId) { showMsg("msg", "No run was named in the link."); return; } if (!runId) { showMsg("msg", "No run was named in the link."); return; }
// ------------------------------------------------------------ polling // A draft is busy while it is being written or drawn. Both are minutes long
// and both report progress on the row, so one poll covers them.
function busyState(d) {
var s = String(d.draw_state || "");
return d.state === "writing" || (s && !/^(drawn|failed)/.test(s));
}
async function load() { async function load(repaint) {
var r = await api("run_status", { run_id: runId }); var r = await api("run_status", { run_id: runId });
if (!r.ok) { showMsg("msg", r.error || "Could not load that run."); return; } if (!r.ok) { showMsg("msg", r.error || "Could not load that run."); return; }
var wasBusy = DRAFTS.length && DRAFTS.some(busyState);
RUN = r.run; DRAFTS = r.drafts || []; RUN = r.run; DRAFTS = r.drafts || [];
document.getElementById("title").textContent = RUN.book_title || "Trailers"; document.getElementById("title").textContent = RUN.book_title || "Trailers";
document.getElementById("sub").textContent = sub(); document.getElementById("sub").textContent = sub();
if (!CUR && DRAFTS.length) CUR = DRAFTS[0].id; if (!CUR && DRAFTS.length) CUR = DRAFTS[0].id;
paintTabs(); paintTabs();
var busy = DRAFTS.some(function (d) { return d.state === "writing"; });
if (busy && !timer) timer = setInterval(load, 4000); var busy = DRAFTS.some(busyState);
if (busy && !timer) timer = setInterval(load, 5000);
if (!busy && timer) { clearInterval(timer); timer = null; } if (!busy && timer) { clearInterval(timer); timer = null; }
if (!busy) openTab(CUR);
else paintWriting(); var me = DRAFTS.filter(function (d) { return d.id === CUR; })[0];
if (me && me.state === "writing") { paintWriting(); return; }
// Repaint when asked, on first paint, or when work has just finished.
if (repaint !== false && (wasBusy || !document.querySelector(".shot"))) openTab(CUR);
else paintBar();
} }
function sub() { function sub() {
var bits = []; var bits = [];
if (RUN.stage) bits.push(RUN.stage); if (RUN.stage) bits.push(RUN.stage);
bits.push(DRAFTS.length + " writer" + (DRAFTS.length === 1 ? "" : "s")); bits.push(DRAFTS.length + " writer" + (DRAFTS.length === 1 ? "" : "s"));
if (RUN.cost_usd) bits.push("$" + Number(RUN.cost_usd).toFixed(2) + " so far"); var spent = DRAFTS.reduce(function (a, d) {
return a + Number(d.write_usd || 0) + Number(d.draw_usd || 0); }, 0);
if (spent) bits.push("$" + spent.toFixed(2) + " so far");
if (RUN.created_at) bits.push(when(RUN.created_at)); if (RUN.created_at) bits.push(when(RUN.created_at));
return bits.join(" · "); return bits.join(" · ");
} }
function paintTabs() { function paintTabs() {
document.getElementById("tabs").innerHTML = DRAFTS.map(function (d) { document.getElementById("tabs").innerHTML = DRAFTS.map(function (d) {
var s = String(d.draw_state || "");
var extra = d.state === "writing" ? '<span class="st">writing…</span>' var extra = d.state === "writing" ? '<span class="st">writing…</span>'
: d.state === "failed" ? '<span class="st">failed</span>' : d.state === "failed" ? '<span class="st">failed</span>'
: (s && !/^(drawn|failed)/.test(s)) ? '<span class="st">' + esc(s) + '</span>'
: d.score ? '<span class="sc">' + d.score + '</span>' : d.score ? '<span class="sc">' + d.score + '</span>'
: ''; : '';
return '<button type="button" class="tab' + (d.id === CUR ? ' on' : '') return '<button type="button" class="tab' + (d.id === CUR ? ' on' : '')
@ -154,16 +172,42 @@
pane.innerHTML = paneHtml(meta, r.draft); pane.innerHTML = paneHtml(meta, r.draft);
} }
function drawBtn(meta) {
var shots = (PKG.shots || []).length, cast = (PKG.characters || []).length;
var imgs = cast * 3 + shots * 2;
var price = imgs * 0.068;
var s = String(meta.draw_state || "");
if (s && !/^(drawn|failed)/.test(s)) {
return '<button type="button" class="btn" disabled>Drawing… ' + esc(s)
+ '</button><span class="soon">Cast sheets first, then every shot. '
+ 'This takes a few minutes.</span>';
}
if (/^drawn/.test(s)) {
return '<button type="button" class="btn" data-draw="' + esc(meta.id)
+ '">Draw it again &middot; $' + price.toFixed(2) + '</button>'
+ '<span class="soon">' + esc(s)
+ (meta.draw_usd ? ' &middot; $' + Number(meta.draw_usd).toFixed(2) + ' spent' : '')
+ '</span>';
}
return '<button type="button" class="btn primary" data-draw="' + esc(meta.id)
+ '">Draw this storyboard &middot; $' + price.toFixed(2) + '</button>'
+ '<span class="soon">' + imgs + ' pictures &mdash; ' + cast
+ ' cast sheets and ' + shots + ' shots, opening and closing.</span>'
+ (s === "failed" && meta.note ? '<span class="soon">' + esc(meta.note) + '</span>' : '');
}
function paintBar() {
var el = document.querySelector(".bar");
var meta = DRAFTS.filter(function (d) { return d.id === CUR; })[0];
if (el && meta) el.innerHTML = drawBtn(meta);
}
function paneHtml(meta, draft) { function paneHtml(meta, draft) {
var p = PKG, shots = p.shots || [], cast = p.characters || []; var p = PKG, shots = p.shots || [], cast = p.characters || [];
var voice = {}; var voice = {};
cast.forEach(function (c) { voice[c.id] = c; }); cast.forEach(function (c) { voice[c.id] = c; });
var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0); var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0);
// Drawing is priced off what this writer actually wrote, not an estimate:
// three sheets a character plus an opening and a closing frame a shot.
var imgs = cast.length * 3 + shots.length * 2;
var price = imgs * 0.068;
var head = '<div class="head"><h2>' + esc(p.title || meta.label) + '</h2>' var head = '<div class="head"><h2>' + esc(p.title || meta.label) + '</h2>'
+ (p.tagline ? '<span class="tag">' + esc(p.tagline) + '</span>' : '') + '</div>' + (p.tagline ? '<span class="tag">' + esc(p.tagline) + '</span>' : '') + '</div>'
@ -182,25 +226,26 @@
+ '</ul></div>'; + '</ul></div>';
} }
var bar = '<div class="bar">'
+ '<button type="button" class="btn" disabled>Draw this storyboard &middot; $'
+ price.toFixed(2) + '</button>'
+ '<span class="soon">' + imgs + ' pictures. Not wired up yet.</span>'
+ '</div>';
var castHtml = '<div class="cast">' + cast.map(function (c) { var castHtml = '<div class="cast">' + cast.map(function (c) {
return '<div class="ch"><b>' + esc(c.name || c.id) + '</b>' var sheets = "";
if (c.head_url || c.front_url || c.side_url) {
sheets = '<div class="sheets">'
+ [c.head_url, c.front_url, c.side_url].map(function (u) {
return u ? '<img src="' + esc(u) + '" alt="">' : ''; }).join("")
+ '</div>';
}
return '<div class="ch">' + sheets + '<b>' + esc(c.name || c.id) + '</b>'
+ '<span class="vo">' + esc(c.role || "") + (c.age ? " · " + esc(c.age) : "") + '<span class="vo">' + esc(c.role || "") + (c.age ? " · " + esc(c.age) : "")
+ (c.voice_id ? " · voiced" : " · silent") + '</span>' + (c.voice_id ? " · voiced" : " · silent") + '</span>'
+ '<div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>'; + '<div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>';
}).join("") + '</div>'; }).join("") + '</div>';
return head + probs + bar + castHtml return head + probs + '<div class="bar">' + drawBtn(meta) + '</div>' + castHtml
+ shots.map(function (s) { return shotHtml(s, voice); }).join(""); + shots.map(function (s) { return shotHtml(s, voice); }).join("");
} }
function shotHtml(s, voice) { function shotHtml(s, voice) {
var lines = (s.audio || []).map(function (a, i) { var lines = (s.audio || []).map(function (a) {
var who = voice[a.character_id] || {}; var who = voice[a.character_id] || {};
return '<div class="line">' return '<div class="line">'
+ '<button type="button" class="play" data-shot="' + esc(s.id) + '" title="Hear this shot">&#9654;</button>' + '<button type="button" class="play" data-shot="' + esc(s.id) + '" title="Hear this shot">&#9654;</button>'
@ -234,9 +279,19 @@
+ '</div></div>'; + '</div></div>';
} }
// ------------------------------------------------------------ play + notes // ------------------------------------------------------------ actions
document.getElementById("pane").addEventListener("click", async function (e) { document.getElementById("pane").addEventListener("click", async function (e) {
var draw = e.target.closest ? e.target.closest("[data-draw]") : null;
if (draw) {
draw.disabled = true;
var d = await api("draw_storyboard", { draft_id: draw.getAttribute("data-draw") });
if (!d.ok) { draw.disabled = false; showMsg("msg", d.error || "That did not start."); return; }
hideMsg("msg");
load();
return;
}
var btn = e.target.closest ? e.target.closest(".play") : null; var btn = e.target.closest ? e.target.closest(".play") : null;
if (!btn) return; if (!btn) return;
var sid = btn.getAttribute("data-shot"); var sid = btn.getAttribute("data-shot");