paytolaugh/trailer.html
gator 5ea49c386c trailer.html: one tab per writer, with a play button and a note box
Reading a trailer tells you what happens; hearing it tells you the line is
four seconds too long and the grandmother sounds thirty. So the play button
ships before the draw button - speaking a whole trailer costs fractions of a
cent against five dollars to draw it - and it prints the spoken length against
the length the writer planned, which is the most common fault in these scripts.

The note box saves as you stop typing. A note you have to remember to save is
a note that gets lost when you switch tabs.

The draw button is priced from what the writer actually wrote - three sheets a
character plus an opening and a closing frame a shot - rather than from an
estimate, and is disabled until that stage lands.
2026-09-17 01:35:29 +00:00

277 lines
12 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:10px;margin:0 0 22px}
.ch{border:1px solid var(--line);border-radius:10px;padding:9px 12px;font-size:13px;max-width:330px}
.ch b{display:block}
.ch .vo{color:var(--muted);font-size:12px}
.ch .ap{color:var(--muted);font-size:12px;margin-top:3px}
.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; }
// ------------------------------------------------------------ polling
async function load() {
var r = await api("run_status", { run_id: runId });
if (!r.ok) { showMsg("msg", r.error || "Could not load that run."); return; }
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(function (d) { return d.state === "writing"; });
if (busy && !timer) timer = setInterval(load, 4000);
if (!busy && timer) { clearInterval(timer); timer = null; }
if (!busy) openTab(CUR);
else paintWriting();
}
function sub() {
var bits = [];
if (RUN.stage) bits.push(RUN.stage);
bits.push(DRAFTS.length + " writer" + (DRAFTS.length === 1 ? "" : "s"));
if (RUN.cost_usd) bits.push("$" + Number(RUN.cost_usd).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 extra = d.state === "writing" ? '<span class="st">writing…</span>'
: d.state === "failed" ? '<span class="st">failed</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 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);
// 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>'
+ (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 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) {
return '<div class="ch"><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 + bar + castHtml
+ shots.map(function (s) { return shotHtml(s, voice); }).join("");
}
function shotHtml(s, voice) {
var lines = (s.audio || []).map(function (a, i) {
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>';
}
// ------------------------------------------------------------ play + notes
document.getElementById("pane").addEventListener("click", async function (e) {
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>