paytolaugh/book.html
gator fa1992f200 book.html: let a book be named, because a filename is not a title
Uploading "Memory Road interior 12-28.pdf" produced a book, a run and a
storyboard all headed "Memory Road interior 12-28". The title is derived from
the filename because that is the only thing an upload carries, and publishers
name files after print runs.

Guessing harder is the wrong fix - stripping "interior 12-28" would be a rule
that works on one file. Letting the author type the title takes eight lines and
is right for every file. The author field goes in at the same time; it costs
nothing and a trailer should know whose book it is.
2026-09-17 02:43:12 +00:00

350 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>Book trailer — PayToLaugh</title>
<meta name="description" content="Upload a manuscript and have several models each write a cinematic trailer from it.">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/assets/app.css">
<style>
.books{margin-top:18px}
.bk{display:flex;gap:14px;align-items:flex-start;padding:14px 0;border-top:1px solid var(--line)}
.bk:first-child{border-top:0}
.bk .meta{flex:1;min-width:0}
.bk h3{margin:0 0 4px;font-size:16px}
.bk .sub{color:var(--muted);font-size:13px}
.bk .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.state{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line)}
.state.ready{color:var(--accent2);border-color:var(--accent2)}
.state.reading{color:var(--muted)}
.state.failed{color:#e0685f;border-color:#e0685f}
.why{color:#e0685f;font-size:13px;margin-top:4px}
.tiny{font-size:12px;color:var(--muted)}
.pick{display:flex;gap:14px;align-items:center;flex-wrap:wrap;
border:1px dashed var(--line);border-radius:12px;padding:18px}
.pick.on{border-color:var(--accent);border-style:solid}
.pick .or{color:var(--muted);font-size:13px}
.rename{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.rename input{font:inherit;font-size:13px;background:transparent;color:var(--ink);
border:1px solid var(--line);border-radius:8px;padding:6px 9px}
.writers{display:flex;flex-wrap:wrap;gap:10px;margin-top:8px}
.wr{display:flex;gap:8px;align-items:center;border:1px solid var(--line);
border-radius:10px;padding:9px 12px;cursor:pointer}
.wr.on{border-color:var(--accent)}
.wr input{margin:0}
.lead{margin-left:6px;font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Make a trailer from a book</h1>
<p>Upload the manuscript and say what you want the trailer to do. Several models each read the whole book and write their own cinematic trailer &mdash; script, cast and shot list. You read them side by side, mark up the shots you want changed, and let them critique each other before one gets filmed.</p>
</header>
<section>
<div class="card">
<div class="msg" id="msg"></div>
<h2 class="sec">1 &middot; The manuscript</h2>
<div class="field">
<label>The book</label>
<span class="hint">A Word <strong>.docx</strong> or a <strong>.pdf</strong>, up to 6MB. A scanned PDF has no text in it and will be refused &mdash; upload the original document instead.</span>
<div class="pick" id="pick">
<button type="button" class="btn primary" id="choose">Choose a file&hellip;</button>
<span class="or" id="pickmsg">or drag the manuscript onto this panel</span>
</div>
<input type="file" id="file" accept=".docx,.pdf,.txt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" style="display:none">
</div>
<div class="books" id="books"><p class="empty">No manuscripts yet.</p></div>
</div>
</section>
<section id="runcard" style="display:none">
<div class="card">
<h2 class="sec">2 &middot; What you want from the trailer</h2>
<div class="field">
<label for="guidance">Your guidance</label>
<span class="hint">You know this book better than any of them will. Say what the trailer has to carry, which scenes matter, which it must not give away, who the audience is, and the tone. This outranks the house defaults.</span>
<textarea id="guidance" rows="7" placeholder="What the trailer has to carry, what it must not give away, and the tone you want."></textarea>
</div>
<h2 class="sec" style="margin-top:34px">3 &middot; Who writes it</h2>
<div class="field">
<label>Writers</label>
<span class="hint">Each one reads the whole book and writes its own trailer, with its own cast. Tick at least two if you want them to critique each other. The <em>lead</em> is the one that will write the revision after the reviews come back &mdash; click a ticked name to make it the lead.</span>
<div class="writers" id="writers"></div>
</div>
<div class="field">
<p class="tiny" id="quote"></p>
<button type="button" class="btn primary" id="go">Write the trailers</button>
</div>
</div>
</section>
</div>
<script src="/assets/app.js"></script>
<script>
// Send a document as the bytes it actually is.
//
// app.js has resizeImage, and it is image-only by construction - FileReader
// into an <img> into a canvas. That is right for a 10MB phone photo and fatal
// for a .docx, which must arrive byte for byte or the zip will not open. This
// lives here rather than in app.js because this is the only page that uploads
// a document, and app.js is loaded by every page.
//
// The ceiling matches the server's MAX_BOOK_BYTES, so an oversized file is
// refused instantly with a suggestion rather than after a slow upload that was
// always going to be rejected.
var MAX_DOC_BYTES = 6 * 1024 * 1024;
function readFileB64(file) {
return new Promise(function (resolve, reject) {
if (!file) { reject(new Error("No file was chosen.")); return; }
if (file.size > MAX_DOC_BYTES) {
reject(new Error("That file is " + (file.size / 1048576).toFixed(1)
+ "MB and the limit is 6MB. Upload the .docx rather than an "
+ "illustrated PDF."));
return;
}
var reader = new FileReader();
reader.onerror = function () { reject(new Error("Could not read that file.")); };
reader.onload = function () {
var s = String(reader.result || "");
var i = s.indexOf(",");
resolve({ b64: i >= 0 ? s.slice(i + 1) : s, name: file.name, bytes: file.size });
};
reader.readAsDataURL(file);
});
}
(function () {
var email = requireSession();
if (!email) return;
navBar("trailer");
var BOOKS = [], PICKED = null, WRITERS = [], CHOSEN = {}, LEAD = "", EDIT = null;
var pollTimer = null;
// ---------------------------------------------------------------- books
function bookHtml(b) {
var st = b.status || "reading";
var sub;
if (st === "ready") {
sub = Number(b.words || 0).toLocaleString() + " words &middot; "
+ Number(b.chars || 0).toLocaleString() + " characters &middot; " + esc(b.kind || "");
} else if (st === "failed") {
sub = "could not be read";
} else {
sub = "reading the manuscript…";
}
// A filename is not a title. Publishers name files after print runs, so
// the derived title is a starting point the author can correct.
var rename = EDIT === b.id
? '<div class="rename">'
+ '<input id="rt" value="' + esc(b.title || "") + '" placeholder="Title">'
+ '<input id="ra" value="' + esc(b.author || "") + '" placeholder="Author">'
+ '<button type="button" class="btn" data-save="' + esc(b.id) + '">Save</button>'
+ '<button type="button" class="btn" data-cancel="1">Cancel</button>'
+ '</div>'
: '';
return '<div class="bk" data-id="' + esc(b.id) + '">'
+ '<div class="meta">'
+ '<h3>' + esc(b.title || "Untitled") + '</h3>'
+ '<div class="sub">' + (b.author ? esc(b.author) + ' &middot; ' : '') + sub
+ (b.created_at ? ' &middot; ' + esc(when(b.created_at)) : '') + '</div>'
+ (st === "failed" && b.note ? '<div class="why">' + esc(b.note) + '</div>' : '')
+ rename
+ '</div>'
+ '<div class="acts">'
+ '<span class="state ' + esc(st) + '">' + esc(st) + '</span>'
+ (st === "ready"
? '<button type="button" class="btn" data-pick="' + esc(b.id) + '">'
+ (PICKED === b.id ? "Selected" : "Use this book") + '</button>'
+ '<button type="button" class="btn" data-edit="' + esc(b.id) + '">Rename</button>'
: '')
+ '<button type="button" class="btn" data-del="' + esc(b.id) + '">Remove</button>'
+ '</div></div>';
}
function paintBooks() {
var el = document.getElementById("books");
if (!BOOKS.length) { el.innerHTML = '<p class="empty">No manuscripts yet.</p>'; return; }
el.innerHTML = BOOKS.map(bookHtml).join("");
var ready = BOOKS.filter(function (b) { return b.status === "ready"; });
if (!PICKED && ready.length === 1) PICKED = ready[0].id;
document.getElementById("runcard").style.display = PICKED ? "" : "none";
}
async function loadBooks() {
var r = await api("list_books", {});
if (!r.ok) { showMsg("msg", r.error || "Could not load your manuscripts."); return; }
BOOKS = r.books || [];
paintBooks();
// Extraction runs on the server after the upload returns, so a book sits
// at "reading" for a moment. Poll only while one actually is.
var busy = BOOKS.some(function (b) { return b.status === "reading"; });
if (busy && !pollTimer) pollTimer = setInterval(loadBooks, 2500);
if (!busy && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
document.getElementById("books").addEventListener("click", async function (e) {
var t = e.target;
var pick = t.getAttribute("data-pick"), del = t.getAttribute("data-del");
var edit = t.getAttribute("data-edit"), save = t.getAttribute("data-save");
if (t.getAttribute("data-cancel")) { EDIT = null; paintBooks(); return; }
if (edit) { EDIT = edit; paintBooks(); return; }
if (save) {
var title = document.getElementById("rt").value.trim();
var author = document.getElementById("ra").value.trim();
if (!title) { showMsg("msg", "A book needs a title."); return; }
t.disabled = true;
var u = await api("update_book", { book_id: save, title: title, author: author });
if (!u.ok) { showMsg("msg", u.error || "That did not save."); t.disabled = false; return; }
hideMsg("msg"); EDIT = null; loadBooks();
return;
}
if (pick) { PICKED = pick; hideMsg("msg"); paintBooks(); paintQuote(); return; }
if (del) {
t.disabled = true;
var r = await api("delete_book", { book_id: del });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); t.disabled = false; return; }
if (PICKED === del) PICKED = null;
loadBooks();
}
});
// ---------------------------------------------------------------- upload
var pick = document.getElementById("pick");
var pickMsg = document.getElementById("pickmsg");
var fileInput = document.getElementById("file");
document.getElementById("choose").addEventListener("click", function () {
fileInput.click();
});
["dragenter", "dragover"].forEach(function (k) {
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.add("on"); });
});
["dragleave", "drop"].forEach(function (k) {
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.remove("on"); });
});
pick.addEventListener("drop", function (e) {
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) send(f);
});
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files[0]) send(fileInput.files[0]);
fileInput.value = "";
});
async function send(file) {
hideMsg("msg");
pickMsg.textContent = "reading " + file.name + "…";
var doc;
try { doc = await readFileB64(file); }
catch (err) { resetPick(); showMsg("msg", err.message); return; }
pickMsg.textContent = "uploading " + file.name + " ("
+ (doc.bytes / 1048576).toFixed(1) + "MB)…";
var r = await api("upload_book", { filename: doc.name, data_b64: doc.b64 });
resetPick();
if (!r.ok) { showMsg("msg", r.error || "That upload did not go through."); return; }
PICKED = null;
// Straight into rename: the derived title is a guess and this is the one
// moment the author is looking at it.
EDIT = r.book && r.book.id;
loadBooks();
}
function resetPick() {
pickMsg.textContent = "or drag the manuscript onto this panel";
}
// ---------------------------------------------------------------- writers
function paintWriters() {
document.getElementById("writers").innerHTML = WRITERS.map(function (w) {
var on = !!CHOSEN[w.id];
return '<label class="wr' + (on ? ' on' : '') + '" data-w="' + esc(w.id) + '">'
+ '<input type="checkbox" data-pick="' + esc(w.id) + '"' + (on ? ' checked' : '') + '>'
+ esc(w.label)
+ (LEAD === w.id ? '<span class="lead">lead</span>' : '')
+ '</label>';
}).join("");
}
document.getElementById("writers").addEventListener("change", function (e) {
var id = e.target.getAttribute("data-pick");
if (!id) return;
if (e.target.checked) { CHOSEN[id] = true; if (!LEAD) LEAD = id; }
else { delete CHOSEN[id]; if (LEAD === id) LEAD = Object.keys(CHOSEN)[0] || ""; }
paintWriters(); paintQuote();
});
// Clicking the name of a ticked writer makes it the lead.
document.getElementById("writers").addEventListener("click", function (e) {
var lab = e.target.closest ? e.target.closest(".wr") : null;
if (!lab || e.target.tagName === "INPUT") return;
var id = lab.getAttribute("data-w");
if (CHOSEN[id]) { LEAD = id; paintWriters(); paintQuote(); }
});
function paintQuote() {
var n = Object.keys(CHOSEN).length;
var el = document.getElementById("quote");
var go = document.getElementById("go");
if (!n) {
el.textContent = "Tick at least one writer.";
go.disabled = true; return;
}
go.disabled = false;
// Writing is the cheap half. Drawing the storyboards is priced separately,
// per writer, once you have read the scripts.
el.innerHTML = n + " writer" + (n > 1 ? "s" : "")
+ " &middot; about $" + (n * 0.85).toFixed(2) + " to write a full-length novel"
+ " &middot; storyboards are priced separately once you have read them";
}
(async function loadWriters() {
var r = await api("camera_presets", {});
WRITERS = (r && r.writers) || [];
if (!WRITERS.length) {
showMsg("msg", "The server has not published a writer list yet.");
return;
}
WRITERS.forEach(function (w) { if (w.trailer_default) { CHOSEN[w.id] = true; } });
LEAD = Object.keys(CHOSEN)[0] || "";
paintWriters(); paintQuote();
})();
document.getElementById("go").addEventListener("click", async function () {
var btn = this;
var writers = Object.keys(CHOSEN);
var guidance = document.getElementById("guidance").value.trim();
if (!PICKED) { showMsg("msg", "Pick a manuscript first."); return; }
if (!writers.length) { showMsg("msg", "Tick at least one writer."); return; }
hideMsg("msg");
btn.disabled = true; btn.textContent = "Starting…";
var r = await api("start_run", {
book_id: PICKED, guidance: guidance,
writers: writers.join(","), lead_writer: LEAD || writers[0]
});
if (!r.ok) {
btn.disabled = false; btn.textContent = "Write the trailers";
showMsg("msg", r.error || "That did not start.");
return;
}
location.href = "/trailer.html?run=" + encodeURIComponent(r.run.id);
});
loadBooks();
})();
</script>
</body>
</html>