paytolaugh/trailer.html
gator c6788afba5 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.
2026-09-17 02:23:59 +00:00

332 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trailers — PayToLaugh</title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/assets/app.css">
<style>
.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:0 0 20px}
.tab{border:1px solid var(--line);border-bottom:0;background:none;color:var(--muted);
padding:10px 16px;border-radius:10px 10px 0 0;cursor:pointer;font:inherit;font-size:14px}
.tab.on{color:var(--ink);border-color:var(--line);background:var(--panel)}
.tab .sc{color:var(--accent2);margin-left:7px;font-weight:600}
.tab .st{color:var(--muted);margin-left:7px;font-size:12px}
.head{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;margin-bottom:6px}
.head h2{margin:0;font-size:22px}
.tag{color:var(--muted);font-style:italic}
.facts{color:var(--muted);font-size:13px;margin:2px 0 18px}
.cast{display:flex;flex-wrap:wrap;gap:14px;margin:0 0 22px}
.ch{border:1px solid var(--line);border-radius:10px;padding:10px;font-size:13px;width:250px}
.ch b{display:block}
.ch .vo{color:var(--muted);font-size:12px}
.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 .num{width:34px;flex:none;color:var(--muted);font-variant-numeric:tabular-nums}
.shot .body{flex:1;min-width:0}
.shot h3{margin:0 0 3px;font-size:15px}
.shot .where{color:var(--muted);font-size:12px;margin-bottom:8px}
.shot .act{font-size:14px;margin:0 0 8px}
.shot .cam,.shot .cont{color:var(--muted);font-size:12.5px;margin:0 0 5px}
.frames{display:flex;gap:10px;margin:10px 0}
.frames figure{margin:0;flex:1;min-width:0}
.frames img{width:100%;border-radius:8px;border:1px solid var(--line);display:block}
.frames figcaption{color:var(--muted);font-size:11px;margin-top:3px}
.line{display:flex;gap:9px;align-items:flex-start;margin:7px 0}
.line .say{flex:1;min-width:0;font-size:14px}
.line .who{color:var(--accent);font-size:12px}
.line .dir{color:var(--muted);font-size:12px}
.play{border:1px solid var(--line);background:none;color:var(--ink);border-radius:999px;
width:30px;height:30px;flex:none;cursor:pointer;font:inherit;line-height:1}
.play:hover{border-color:var(--accent)}
.dur{color:var(--muted);font-size:12px;align-self:center}
.dur.over{color:#e0685f}
.note{width:100%;box-sizing:border-box;margin-top:8px;font:inherit;font-size:13px;
background:transparent;color:var(--ink);border:1px dashed var(--line);
border-radius:8px;padding:8px 10px;resize:vertical}
.note:focus{border-style:solid;border-color:var(--accent);outline:none}
.note.saved{border-color:var(--accent2)}
.probs{border:1px solid #e0685f;border-radius:10px;padding:12px 14px;margin:0 0 18px;font-size:13px}
.probs b{color:#e0685f}
.probs ul{margin:6px 0 0;padding-left:18px}
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0 0 20px}
.soon{color:var(--muted);font-size:12px}
.lip{color:var(--accent2);font-size:11px;border:1px solid var(--accent2);
border-radius:999px;padding:0 6px}
.vobadge{color:var(--muted);font-size:11px;border:1px solid var(--line);
border-radius:999px;padding:0 6px}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="title">Trailers</h1>
<p id="sub"></p>
</header>
<div class="msg" id="msg"></div>
<section><div class="card">
<div class="tabs" id="tabs"></div>
<div id="pane"><p class="empty">Loading&hellip;</p></div>
</div></section>
</div>
<script src="/assets/app.js"></script>
<script>
(function () {
if (!requireSession()) return;
navBar("trailer");
var RUN = null, DRAFTS = [], CUR = null, PKG = {}, NOTES = {}, timer = null;
var runId = new URLSearchParams(location.search).get("run") || "";
var audio = new Audio();
if (!runId) { showMsg("msg", "No run was named in the link."); return; }
// 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(repaint) {
var r = await api("run_status", { run_id: runId });
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 || [];
document.getElementById("title").textContent = RUN.book_title || "Trailers";
document.getElementById("sub").textContent = sub();
if (!CUR && DRAFTS.length) CUR = DRAFTS[0].id;
paintTabs();
var busy = DRAFTS.some(busyState);
if (busy && !timer) timer = setInterval(load, 5000);
if (!busy && timer) { clearInterval(timer); timer = null; }
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() {
var bits = [];
if (RUN.stage) bits.push(RUN.stage);
bits.push(DRAFTS.length + " writer" + (DRAFTS.length === 1 ? "" : "s"));
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));
return bits.join(" · ");
}
function paintTabs() {
document.getElementById("tabs").innerHTML = DRAFTS.map(function (d) {
var s = String(d.draw_state || "");
var extra = d.state === "writing" ? '<span class="st">writing…</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>'
: '';
return '<button type="button" class="tab' + (d.id === CUR ? ' on' : '')
+ '" data-tab="' + esc(d.id) + '">' + esc(d.label || d.writer)
+ extra + '</button>';
}).join("");
}
function paintWriting() {
document.getElementById("pane").innerHTML =
'<p class="empty">Each writer is reading the whole book and writing its own '
+ 'trailer. This takes a few minutes.</p>';
}
document.getElementById("tabs").addEventListener("click", function (e) {
var id = e.target.closest ? e.target.closest(".tab") : null;
if (!id) return;
CUR = id.getAttribute("data-tab");
paintTabs(); openTab(CUR);
});
// ------------------------------------------------------------ one trailer
async function openTab(id) {
var meta = DRAFTS.filter(function (d) { return d.id === id; })[0];
if (!meta) return;
var pane = document.getElementById("pane");
if (meta.state === "failed") {
pane.innerHTML = '<p class="empty"><b>' + esc(meta.label) + ' did not finish.</b><br>'
+ esc(meta.note || "No reason was recorded.") + '</p>';
return;
}
if (meta.state === "writing") { paintWriting(); return; }
pane.innerHTML = '<p class="empty">Loading…</p>';
var r = await api("get_draft", { draft_id: id });
if (!r.ok) { pane.innerHTML = '<p class="empty">' + esc(r.error || "") + '</p>'; return; }
PKG = r.draft.package || {};
NOTES = r.draft.notes || {};
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) {
var p = PKG, shots = p.shots || [], cast = p.characters || [];
var voice = {};
cast.forEach(function (c) { voice[c.id] = c; });
var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0);
var head = '<div class="head"><h2>' + esc(p.title || meta.label) + '</h2>'
+ (p.tagline ? '<span class="tag">' + esc(p.tagline) + '</span>' : '') + '</div>'
+ (p.logline ? '<p>' + esc(p.logline) + '</p>' : '')
+ '<p class="facts">' + esc(meta.label) + ' &middot; ' + shots.length + ' shots &middot; '
+ Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, "0")
+ ' &middot; ' + cast.length + ' characters'
+ (meta.write_usd ? ' &middot; $' + Number(meta.write_usd).toFixed(2) + ' to write' : '')
+ '</p>';
var probs = "";
if (draft.problems) {
probs = '<div class="probs"><b>The checker still has objections</b><ul>'
+ draft.problems.split("\n").filter(Boolean).map(function (x) {
return '<li>' + esc(x) + '</li>'; }).join("")
+ '</ul></div>';
}
var castHtml = '<div class="cast">' + cast.map(function (c) {
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) : "")
+ (c.voice_id ? " · voiced" : " · silent") + '</span>'
+ '<div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>';
}).join("") + '</div>';
return head + probs + '<div class="bar">' + drawBtn(meta) + '</div>' + castHtml
+ shots.map(function (s) { return shotHtml(s, voice); }).join("");
}
function shotHtml(s, voice) {
var lines = (s.audio || []).map(function (a) {
var who = voice[a.character_id] || {};
return '<div class="line">'
+ '<button type="button" class="play" data-shot="' + esc(s.id) + '" title="Hear this shot">&#9654;</button>'
+ '<div class="say"><span class="who">' + esc(who.name || a.character_id) + '</span> '
+ (a.lip_sync ? '<span class="lip">on camera</span>' : '<span class="vobadge">voice over</span>')
+ '<br>' + esc(a.text || "")
+ (a.delivery ? '<br><span class="dir">' + esc(a.delivery) + '</span>' : '')
+ '</div><span class="dur" data-dur="' + esc(s.id) + '"></span></div>';
}).join("");
var frames = "";
if (s.opening_url || s.closing_url) {
frames = '<div class="frames">'
+ (s.opening_url ? '<figure><img src="' + esc(s.opening_url) + '" alt=""><figcaption>opens on</figcaption></figure>' : '')
+ (s.closing_url ? '<figure><img src="' + esc(s.closing_url) + '" alt=""><figcaption>ends on</figcaption></figure>' : '')
+ '</div>';
}
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
+ '<h3>' + esc(s.title || "") + '</h3>'
+ '<div class="where">' + esc(s.setting || "") + ' &middot; '
+ Number(s.edit_seconds || 0).toFixed(1) + 's</div>'
+ frames
+ '<p class="act">' + esc(s.action || "") + '</p>'
+ (s.camera ? '<p class="cam">camera: ' + esc(s.camera) + '</p>' : '')
+ (s.continuity ? '<p class="cont">continuity: ' + esc(s.continuity) + '</p>' : '')
+ lines
+ '<textarea class="note" rows="2" data-note="' + esc(s.id) + '" '
+ 'placeholder="Your note on this shot. It gets the highest priority in the revision.">'
+ esc(NOTES[s.id] || "") + '</textarea>'
+ '</div></div>';
}
// ------------------------------------------------------------ actions
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;
if (!btn) return;
var sid = btn.getAttribute("data-shot");
btn.disabled = true; btn.innerHTML = "&hellip;";
var r = await api("shot_audio", { draft_id: CUR, shot_id: sid });
btn.disabled = false; btn.innerHTML = "&#9654;";
if (!r.ok) { showMsg("msg", r.error || "That would not play."); return; }
hideMsg("msg");
if (!r.url) return;
// Spoken length against the length the writer planned for. A line that
// overruns its shot is the single most common fault in these scripts.
document.querySelectorAll('[data-dur="' + sid + '"]').forEach(function (el) {
el.textContent = r.seconds.toFixed(1) + "s / " + Number(r.planned).toFixed(1) + "s";
el.className = "dur" + (r.seconds > Number(r.planned) ? " over" : "");
});
audio.pause(); audio.src = r.url; audio.play();
});
var noteTimers = {};
document.getElementById("pane").addEventListener("input", function (e) {
var ta = e.target;
var sid = ta.getAttribute && ta.getAttribute("data-note");
if (!sid) return;
ta.classList.remove("saved");
clearTimeout(noteTimers[sid]);
// Saved as you stop typing. A note the author has to remember to save is
// a note that gets lost between tabs.
noteTimers[sid] = setTimeout(async function () {
var r = await api("add_shot_note", { draft_id: CUR, shot_id: sid, note: ta.value });
if (r.ok) { NOTES[sid] = ta.value; ta.classList.add("saved"); }
}, 700);
});
load();
})();
</script>
</body>
</html>