Visual shot editor: start image, scene and camera move editable per shot

This commit is contained in:
gator 2026-09-06 15:24:34 +00:00
parent 34150bb264
commit 8a3324be31
2 changed files with 158 additions and 55 deletions

View file

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>New video — PayToLaugh</title> <title>New video — PayToLaugh</title>
<meta name="description" content="Pick your pet, paste a link, tell the director what you think."> <meta name="description" content="Pick your pet, paste a link, set the scene, tell the director what you think.">
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css">
</head> </head>
<body> <body>
@ -12,7 +12,7 @@
<header class="page"> <header class="page">
<h1>Make a new video</h1> <h1>Make a new video</h1>
<p>Pick the pet, paste whatever you want them to react to, and tell the director what you actually think. The opinion is yours; the delivery is theirs.</p> <p>Pick the pet, paste whatever you want them to react to, and tell the director what you actually think. The opinion is yours; the delivery is theirs. You can direct every shot afterwards.</p>
</header> </header>
<section> <section>
@ -38,6 +38,13 @@
<span class="hint">Say what you think and how you want it played. Two sentences is plenty.</span> <span class="hint">Say what you think and how you want it played. Two sentences is plenty.</span>
<textarea id="notes" placeholder="Astro has hated our robot vacuum since day one and barks at it every time it docks. Play it like he is filing a formal grievance with management."></textarea> <textarea id="notes" placeholder="Astro has hated our robot vacuum since day one and barks at it every time it docks. Play it like he is filing a formal grievance with management."></textarea>
</div> </div>
<h2 class="sec" style="margin-top:34px">3 &middot; Where it happens</h2>
<div class="field">
<label for="scene">The setting <span style="color:var(--muted);font-weight:400">(optional)</span></label>
<span class="hint">A starting point for every shot, which you can change shot by shot later. Real or not: a podcast booth, a field of tall grass, floating on a surfboard.</span>
<input type="text" id="scene" placeholder="a podcast booth with warm lamps behind">
</div>
<div class="field"> <div class="field">
<label>Do they agree or disagree?</label> <label>Do they agree or disagree?</label>
<div class="chips" id="stance"> <div class="chips" id="stance">
@ -48,27 +55,28 @@
</div> </div>
</div> </div>
<h2 class="sec" style="margin-top:34px">3 &middot; How long</h2> <h2 class="sec" style="margin-top:34px">4 &middot; How long</h2>
<div class="field"> <div class="field">
<div class="chips" id="len"> <div class="chips" id="len">
<span class="chip" data-v="2">16 seconds</span> <span class="chip" data-v="2">16 seconds</span>
<span class="chip on" data-v="4">32 seconds</span> <span class="chip on" data-v="4">32 seconds</span>
<span class="chip" data-v="6">48 seconds</span> <span class="chip" data-v="6">48 seconds</span>
</div> </div>
<span class="hint" style="margin-top:10px;display:block">The piece is written as one continuous monologue, then cut into 8 second shots that flow into each other.</span>
</div> </div>
<div class="quote"> <div class="quote">
<div><div class="big" id="price">$8.85</div><div class="sub" id="plen">32 seconds &middot; 4 shots</div></div> <div><div class="big" id="price">$8.85</div><div class="sub" id="plen">32 seconds &middot; 4 shots</div></div>
<div class="sub">You approve the script before anything is filmed.</div> <div class="sub">You direct and approve before anything is filmed.</div>
</div> </div>
<button class="btn" id="go" type="submit" style="margin-top:26px">Submit for scripting</button> <button class="btn" id="go" type="submit" style="margin-top:26px">Write the script</button>
</form> </form>
</div> </div>
<p class="note" style="margin-top:22px"> <p class="note" style="margin-top:22px">
<strong>What happens next.</strong> Submitting records your request and puts it in the <strong>What happens next.</strong> The writer starts straight away. You will be able to
queue. Nothing is charged and nothing is filmed yet. Payment and automatic read it, change the setting and camera on every shot, ask for rewrites, and approve it.
production get switched on once proper sign-in is in place. Nothing is charged and nothing is filmed yet.
</p> </p>
</section> </section>
@ -129,21 +137,23 @@ document.getElementById("form").addEventListener("submit", async function (e) {
var btn = document.getElementById("go"); var btn = document.getElementById("go");
var url = document.getElementById("url").value.trim(); var url = document.getElementById("url").value.trim();
var notes = document.getElementById("notes").value.trim(); var notes = document.getElementById("notes").value.trim();
var scene = document.getElementById("scene").value.trim();
var opt = petSel.options[petSel.selectedIndex]; var opt = petSel.options[petSel.selectedIndex];
if (!url) { showMsg("msg", "Paste a link to whatever you want them to react to."); return; } if (!url) { showMsg("msg", "Paste a link to whatever you want them to react to."); return; }
if (notes.length < 10) { showMsg("msg", "Tell the director what you think, in a sentence or two."); return; } if (notes.length < 10) { showMsg("msg", "Tell the director what you think, in a sentence or two."); return; }
btn.disabled = true; btn.textContent = "Submitting…"; btn.disabled = true; btn.textContent = "Writing…";
var r = await api("create_project", { var r = await api("create_project", {
email: email, pet_id: petSel.value, email: email, pet_id: petSel.value,
pet_name: opt ? opt.getAttribute("data-name") : "", pet_name: opt ? opt.getAttribute("data-name") : "",
source_url: url, owner_notes: notes, stance: stance, segments: segments source_url: url, owner_notes: notes, scene: scene,
stance: stance, segments: segments
}); });
btn.disabled = false; btn.textContent = "Submit for scripting"; btn.disabled = false; btn.textContent = "Write the script";
if (!r.ok) { showMsg("msg", r.error || "That did not go through. Please try again."); return; } if (!r.ok) { showMsg("msg", r.error || "That did not go through. Please try again."); return; }
showMsg("msg", "Submitted. Taking you to your videos…", "ok"); showMsg("msg", "Writing it now…", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 650); setTimeout(function () { location.href = "/script.html?id=" + encodeURIComponent(r.project.id); }, 500);
}); });
</script> </script>
</body> </body>

View file

@ -3,17 +3,28 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review the script — PayToLaugh</title> <title>Direct the shots — PayToLaugh</title>
<meta name="description" content="Read the script, ask for changes, approve it when it sounds right."> <meta name="description" content="Read the script, set the scene and camera for each shot, then approve it.">
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css">
<style> <style>
.seg{border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:14px;background:#0f1117} .shot{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
.seg .top{display:flex;justify-content:space-between;gap:14px;align-items:baseline;margin-bottom:10px;flex-wrap:wrap} display:grid;grid-template-columns:190px 1fr;gap:22px;align-items:start}
.seg .n{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--accent)} @media(max-width:720px){.shot{grid-template-columns:1fr}}
.seg .cam{font:11px/1 var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .shot .still{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);
.seg .line{font-size:18px;line-height:1.5;margin:0} object-fit:cover;display:block;background:#000}
.seg .foot{font:11.5px/1 var(--mono);color:var(--muted);margin-top:10px} .shot .noimg{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px dashed var(--line);
background:#0f1117;display:flex;align-items:center;justify-content:center;text-align:center;
color:var(--muted);font-size:12.5px;padding:14px}
.shot .n{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
.shot .cont{font:11px/1 var(--mono);color:var(--accent2);margin-left:8px}
.shot .line{font-size:17px;line-height:1.5;margin:10px 0 16px}
.shot .foot{font:11.5px/1 var(--mono);color:var(--muted)}
.mini{font-size:13px;font-weight:600;margin:0 0 5px;display:block}
.mini span{color:var(--muted);font-weight:400}
.shot textarea{min-height:62px}
.warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px} .warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px}
.full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px;
color:var(--muted);font-size:15.5px;line-height:1.7}
</style> </style>
</head> </head>
<body> <body>
@ -21,7 +32,7 @@
<header class="page"> <header class="page">
<h1 id="title">The script</h1> <h1 id="title">The script</h1>
<p id="sub">Read it through. If it does not sound like your pet, say so and we will rewrite it.</p> <p id="sub">Read it through, then direct it. Every shot can have its own setting and camera move.</p>
</header> </header>
<section> <section>
@ -37,31 +48,60 @@
navBar("videos"); navBar("videos");
var email = requireSession(); var email = requireSession();
var pid = new URLSearchParams(location.search).get("id"); var pid = new URLSearchParams(location.search).get("id");
var poll = null; var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
if (!pid) { if (!pid) {
document.getElementById("out").innerHTML = document.getElementById("out").innerHTML =
'<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</div>'; '<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</div>';
} }
function segHtml(s) { function photoOptions(sel) {
return '<div class="seg">' if (!PHOTOS.length) return '<option value="">No photos uploaded</option>';
+ '<div class="top"><span class="n">Shot ' + (s.index || "") + '</span>' return PHOTOS.map(function (p) {
+ '<span class="cam">' + esc(s.camera || "") + '</span></div>' var label = p.kind.charAt(0).toUpperCase() + p.kind.slice(1);
+ '<p class="line">' + esc(s.line || "") + '</p>' return '<option value="' + esc(p.id) + '"' + (p.id === sel ? ' selected' : '') + '>'
+ '<div class="foot">' + (s.word_count || 0) + ' words · about ' + (s.est_seconds || 0) + 's' + esc(label) + '</option>';
+ (s.note ? ' · ' + esc(s.note) : '') + '</div>' }).join("");
}
function shotHtml(s, locked) {
var img = s.plate_url
? '<img class="still" src="' + esc(s.plate_url) + '" alt="Shot ' + s.index + ' opening frame">'
: '<div class="noimg">No opening frame yet.<br>Set the scene, then make one.</div>';
var controls = locked ? '' :
'<label class="mini">The setting <span>— anywhere, including places a real pet could not be</span></label>'
+ '<textarea data-scene="' + s.index + '" placeholder="a field of tall grass at golden hour">' + esc(s.scene || "") + '</textarea>'
+ '<label class="mini" style="margin-top:12px">Camera</label>'
+ '<input type="text" list="cams" data-cam="' + s.index + '" value="' + esc(s.camera || "") + '" placeholder="tight head shot, camera static">'
+ '<div class="row" style="margin-top:12px">'
+ '<div><label class="mini">Which photo of them</label>'
+ '<select data-photo="' + s.index + '">' + photoOptions(s.photo_id) + '</select></div>'
+ '<div style="display:flex;align-items:flex-end">'
+ '<button type="button" class="btn ghost" data-make="' + s.index + '" style="width:100%">'
+ (s.plate_url ? "Remake this frame" : "Make the frame") + '</button></div>'
+ '</div>'; + '</div>';
return '<div class="shot">'
+ '<div>' + img + '</div>'
+ '<div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ (s.continues ? '<span class="cont">continues the last shot</span>' : '')
+ '<p class="line">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words · about ' + (s.est_seconds || 0) + 's</p>'
+ '<div style="margin-top:16px">' + controls + '</div>'
+ '</div></div>';
} }
function render(r) { function render(r) {
DATA = r;
var p = r.project, s = r.script, out = document.getElementById("out"); var p = r.project, s = r.script, out = document.getElementById("out");
var used = parseInt(p.revisions || 0, 10); var used = parseInt(p.revisions || 0, 10);
var left = (r.max_revisions || 3) - used; var left = (r.max_revisions || 3) - used;
if (p.status === "scripting") { if (p.status === "scripting") {
out.innerHTML = '<div class="card"><p style="margin:0;color:var(--muted)">' out.innerHTML = '<div class="card"><p style="margin:0;color:var(--muted)">'
+ 'Writing the script now. This usually takes under a minute — the page will update itself.</p></div>'; + 'Writing now. This usually takes under a minute — the page updates itself.</p></div>';
if (!poll) poll = setInterval(load, 5000); if (!poll) poll = setInterval(load, 5000);
return; return;
} }
@ -72,42 +112,88 @@ function render(r) {
+ '<a class="btn" style="display:inline-block;text-decoration:none" href="/create.html">Start again</a></div>'; + '<a class="btn" style="display:inline-block;text-decoration:none" href="/create.html">Start again</a></div>';
return; return;
} }
if (!s || !s.segments) { if (!s || !s.segments) { out.innerHTML = '<div class="empty">No script yet.</div>'; return; }
out.innerHTML = '<div class="empty">No script on this project yet.</div>';
return;
}
document.getElementById("title").textContent = s.title || "The script"; document.getElementById("title").textContent = s.title || "The script";
document.getElementById("sub").textContent = s.premise || ""; document.getElementById("sub").textContent = s.premise || "";
var approved = p.status === "approved"; var locked = p.status === "approved";
var warn = (s._warnings && s._warnings.length) var warn = (s._warnings && s._warnings.length)
? '<div class="warn"><strong>Worth a look:</strong> ' + s._warnings.map(esc).join(" · ") + '</div>' ? '<div class="warn"><strong>Worth a look:</strong> ' + s._warnings.map(esc).join(" · ") + '</div>' : "";
: "";
var head = '<div class="quote" style="margin-bottom:22px">' var head = '<div class="quote" style="margin-bottom:22px">'
+ '<div><div class="big">$' + Number(p.price_usd || 0).toFixed(2) + '</div>' + '<div><div class="big">$' + Number(p.price_usd || 0).toFixed(2) + '</div>'
+ '<div class="sub">' + ((p.segments || 4) * 8) + ' seconds · ' + (p.segments || 4) + ' shots</div></div>' + '<div class="sub">' + ((p.segments || 4) * 8) + ' seconds · ' + (p.segments || 4) + ' shots</div></div>'
+ '<div class="sub">' + (approved ? "Approved" : left + " rewrite" + (left === 1 ? "" : "s") + " left") + '</div>' + '<div class="sub">' + (locked ? "Approved" : left + " rewrite" + (left === 1 ? "" : "s") + " left") + '</div></div>';
+ '</div>';
var actions = approved var full = s.full_text
? '<div class="card"><p style="margin:0"><strong>Approved.</strong> Filming is switched on once payment is live. ' ? '<h2 class="sec">The whole piece</h2><div class="full">' + esc(s.full_text) + '</div>' : "";
+ 'Nothing has been charged.</p></div>'
var cams = '<datalist id="cams">' + PRESETS.map(function (c) {
return '<option value="' + esc(c) + '">'; }).join("") + '</datalist>';
var actions = locked
? '<div class="card"><p style="margin:0"><strong>Approved.</strong> Filming is switched on once payment is live. Nothing has been charged.</p></div>'
: '<div class="card">' : '<div class="card">'
+ '<h2 class="sec">Not quite right?</h2>' + '<h2 class="sec">Change the words</h2>'
+ '<div class="field">' + '<div class="field"><label for="fb">What would you change</label>'
+ '<label for="fb">What would you change</label>' + '<span class="hint">Be specific. “Too polite, he would be smugger” beats “make it funnier”. '
+ '<span class="hint">Be specific. “Too polite, he would be smugger about it” beats “make it funnier”.</span>' + 'Setting and camera you can just edit directly above.</span>'
+ '<textarea id="fb" ' + (left <= 0 ? "disabled" : "") + ' placeholder="The third shot is too wordy, and he would never admit the robot is good at anything."></textarea>' + '<textarea id="fb" ' + (left <= 0 ? "disabled" : "") + ' placeholder="Shot three is too wordy, and he would never admit the robot is good at anything."></textarea></div>'
+ '</div>'
+ '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>' + '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>'
+ (left > 0 ? "Rewrite it" : "No rewrites left") + '</button>' + (left > 0 ? "Rewrite the words" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve this script</button>' + '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</button>'
+ '</div>'; + '</div>';
out.innerHTML = head + warn + s.segments.map(segHtml).join("") + actions; out.innerHTML = cams + head + warn + full
+ '<h2 class="sec">The shots</h2>'
+ s.segments.map(function (x) { return shotHtml(x, locked); }).join("")
+ actions;
wire(locked);
}
function wire(locked) {
if (locked) { bindBottom(); return; }
// Save edits when the field loses focus, so typing is never interrupted.
[].forEach.call(document.querySelectorAll("[data-scene],[data-cam],[data-photo]"), function (el) {
var evt = el.tagName === "SELECT" ? "change" : "blur";
el.addEventListener(evt, async function () {
var idx = el.getAttribute("data-scene") || el.getAttribute("data-cam") || el.getAttribute("data-photo");
var body = { email: email, project_id: pid, index: parseInt(idx, 10) };
if (el.hasAttribute("data-scene")) body.scene = el.value;
if (el.hasAttribute("data-cam")) body.camera = el.value;
if (el.hasAttribute("data-photo")) body.photo_id = el.value;
var r = await api("update_shot", body);
if (!r.ok) showMsg("msg", r.error || "That change did not save.");
else hideMsg("msg");
});
});
[].forEach.call(document.querySelectorAll("[data-make]"), function (b) {
b.addEventListener("click", async function () {
hideMsg("msg");
var idx = parseInt(b.getAttribute("data-make"), 10);
// Flush any pending edits in this card before drawing from them.
var card = b.closest(".shot");
var sc = card.querySelector("[data-scene]"), cm = card.querySelector("[data-cam]"),
ph = card.querySelector("[data-photo]");
await api("update_shot", { email: email, project_id: pid, index: idx,
scene: sc ? sc.value : undefined, camera: cm ? cm.value : undefined,
photo_id: ph ? ph.value : undefined });
b.disabled = true; b.textContent = "Drawing…";
var r = await api("make_shot_image", { email: email, project_id: pid, index: idx });
b.disabled = false;
if (!r.ok) { b.textContent = "Try again"; showMsg("msg", r.error || "Could not make that frame."); return; }
load();
});
});
bindBottom();
}
function bindBottom() {
var rv = document.getElementById("revise"); var rv = document.getElementById("revise");
if (rv) rv.onclick = async function () { if (rv) rv.onclick = async function () {
hideMsg("msg"); hideMsg("msg");
@ -115,17 +201,16 @@ function render(r) {
if (fb.length < 5) { showMsg("msg", "Tell us what you would like changed."); return; } if (fb.length < 5) { showMsg("msg", "Tell us what you would like changed."); return; }
rv.disabled = true; rv.textContent = "Sending…"; rv.disabled = true; rv.textContent = "Sending…";
var res = await api("revise_script", { email: email, project_id: pid, feedback: fb }); var res = await api("revise_script", { email: email, project_id: pid, feedback: fb });
if (!res.ok) { rv.disabled = false; rv.textContent = "Rewrite it"; showMsg("msg", res.error || "That did not go through."); return; } if (!res.ok) { rv.disabled = false; rv.textContent = "Rewrite the words"; showMsg("msg", res.error || "That did not go through."); return; }
showMsg("msg", "Rewriting now…", "ok"); showMsg("msg", "Rewriting now…", "ok");
load(); load();
}; };
var ap = document.getElementById("approve"); var ap = document.getElementById("approve");
if (ap) ap.onclick = async function () { if (ap) ap.onclick = async function () {
hideMsg("msg"); hideMsg("msg");
ap.disabled = true; ap.textContent = "Approving…"; ap.disabled = true; ap.textContent = "Approving…";
var res = await api("approve_script", { email: email, project_id: pid }); var res = await api("approve_script", { email: email, project_id: pid });
if (!res.ok) { ap.disabled = false; ap.textContent = "Approve this script"; showMsg("msg", res.error || "That did not go through."); return; } if (!res.ok) { ap.disabled = false; ap.textContent = "Approve and lock it"; showMsg("msg", res.error || "That did not go through."); return; }
showMsg("msg", res.status_text || "Approved.", "ok"); showMsg("msg", res.status_text || "Approved.", "ok");
load(); load();
}; };
@ -133,8 +218,16 @@ function render(r) {
async function load() { async function load() {
if (!pid || !email) return; if (!pid || !email) return;
if (!PRESETS.length) {
var c = await api("camera_presets", {});
if (c.ok) PRESETS = c.presets || [];
}
var r = await api("get_project", { email: email, project_id: pid }); var r = await api("get_project", { email: email, project_id: pid });
if (!r.ok) { showMsg("msg", r.error || "Could not load that script."); return; } if (!r.ok) { showMsg("msg", r.error || "Could not load that script."); return; }
if (!PHOTOS.length && r.project && r.project.pet_id) {
var ph = await api("list_photos", { email: email, pet_id: r.project.pet_id });
if (ph.ok) PHOTOS = ph.photos || [];
}
render(r); render(r);
} }
load(); load();