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 name="viewport" content="width=device-width, initial-scale=1">
<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">
</head>
<body>
@ -12,7 +12,7 @@
<header class="page">
<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>
<section>
@ -38,6 +38,13 @@
<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>
</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">
<label>Do they agree or disagree?</label>
<div class="chips" id="stance">
@ -48,27 +55,28 @@
</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="chips" id="len">
<span class="chip" data-v="2">16 seconds</span>
<span class="chip on" data-v="4">32 seconds</span>
<span class="chip" data-v="6">48 seconds</span>
</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 class="quote">
<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>
<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>
</div>
<p class="note" style="margin-top:22px">
<strong>What happens next.</strong> Submitting records your request and puts it in the
queue. Nothing is charged and nothing is filmed yet. Payment and automatic
production get switched on once proper sign-in is in place.
<strong>What happens next.</strong> The writer starts straight away. You will be able to
read it, change the setting and camera on every shot, ask for rewrites, and approve it.
Nothing is charged and nothing is filmed yet.
</p>
</section>
@ -129,21 +137,23 @@ document.getElementById("form").addEventListener("submit", async function (e) {
var btn = document.getElementById("go");
var url = document.getElementById("url").value.trim();
var notes = document.getElementById("notes").value.trim();
var scene = document.getElementById("scene").value.trim();
var opt = petSel.options[petSel.selectedIndex];
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; }
btn.disabled = true; btn.textContent = "Submitting…";
btn.disabled = true; btn.textContent = "Writing…";
var r = await api("create_project", {
email: email, pet_id: petSel.value,
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; }
showMsg("msg", "Submitted. Taking you to your videos…", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 650);
showMsg("msg", "Writing it now…", "ok");
setTimeout(function () { location.href = "/script.html?id=" + encodeURIComponent(r.project.id); }, 500);
});
</script>
</body>

View file

@ -3,17 +3,28 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review the script — PayToLaugh</title>
<meta name="description" content="Read the script, ask for changes, approve it when it sounds right.">
<title>Direct the shots — PayToLaugh</title>
<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">
<style>
.seg{border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:14px;background:#0f1117}
.seg .top{display:flex;justify-content:space-between;gap:14px;align-items:baseline;margin-bottom:10px;flex-wrap:wrap}
.seg .n{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
.seg .cam{font:11px/1 var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.seg .line{font-size:18px;line-height:1.5;margin:0}
.seg .foot{font:11.5px/1 var(--mono);color:var(--muted);margin-top:10px}
.shot{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
display:grid;grid-template-columns:190px 1fr;gap:22px;align-items:start}
@media(max-width:720px){.shot{grid-template-columns:1fr}}
.shot .still{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);
object-fit:cover;display:block;background:#000}
.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}
.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>
</head>
<body>
@ -21,7 +32,7 @@
<header class="page">
<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>
<section>
@ -37,31 +48,60 @@
navBar("videos");
var email = requireSession();
var pid = new URLSearchParams(location.search).get("id");
var poll = null;
var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
if (!pid) {
document.getElementById("out").innerHTML =
'<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</div>';
}
function segHtml(s) {
return '<div class="seg">'
+ '<div class="top"><span class="n">Shot ' + (s.index || "") + '</span>'
+ '<span class="cam">' + esc(s.camera || "") + '</span></div>'
+ '<p class="line">' + esc(s.line || "") + '</p>'
+ '<div class="foot">' + (s.word_count || 0) + ' words · about ' + (s.est_seconds || 0) + 's'
+ (s.note ? ' · ' + esc(s.note) : '') + '</div>'
function photoOptions(sel) {
if (!PHOTOS.length) return '<option value="">No photos uploaded</option>';
return PHOTOS.map(function (p) {
var label = p.kind.charAt(0).toUpperCase() + p.kind.slice(1);
return '<option value="' + esc(p.id) + '"' + (p.id === sel ? ' selected' : '') + '>'
+ esc(label) + '</option>';
}).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>';
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) {
DATA = r;
var p = r.project, s = r.script, out = document.getElementById("out");
var used = parseInt(p.revisions || 0, 10);
var left = (r.max_revisions || 3) - used;
if (p.status === "scripting") {
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);
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>';
return;
}
if (!s || !s.segments) {
out.innerHTML = '<div class="empty">No script on this project yet.</div>';
return;
}
if (!s || !s.segments) { out.innerHTML = '<div class="empty">No script yet.</div>'; return; }
document.getElementById("title").textContent = s.title || "The script";
document.getElementById("sub").textContent = s.premise || "";
var approved = p.status === "approved";
var locked = p.status === "approved";
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">'
+ '<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">' + (approved ? "Approved" : left + " rewrite" + (left === 1 ? "" : "s") + " left") + '</div>'
+ '</div>';
+ '<div class="sub">' + (locked ? "Approved" : left + " rewrite" + (left === 1 ? "" : "s") + " left") + '</div></div>';
var actions = approved
? '<div class="card"><p style="margin:0"><strong>Approved.</strong> Filming is switched on once payment is live. '
+ 'Nothing has been charged.</p></div>'
var full = s.full_text
? '<h2 class="sec">The whole piece</h2><div class="full">' + esc(s.full_text) + '</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">'
+ '<h2 class="sec">Not quite right?</h2>'
+ '<div class="field">'
+ '<label for="fb">What would you change</label>'
+ '<span class="hint">Be specific. “Too polite, he would be smugger about it” beats “make it funnier”.</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>'
+ '</div>'
+ '<h2 class="sec">Change the words</h2>'
+ '<div class="field"><label for="fb">What would you change</label>'
+ '<span class="hint">Be specific. “Too polite, he would be smugger” beats “make it funnier”. '
+ 'Setting and camera you can just edit directly above.</span>'
+ '<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>'
+ '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>'
+ (left > 0 ? "Rewrite it" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve this script</button>'
+ (left > 0 ? "Rewrite the words" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</button>'
+ '</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");
if (rv) rv.onclick = async function () {
hideMsg("msg");
@ -115,17 +201,16 @@ function render(r) {
if (fb.length < 5) { showMsg("msg", "Tell us what you would like changed."); return; }
rv.disabled = true; rv.textContent = "Sending…";
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");
load();
};
var ap = document.getElementById("approve");
if (ap) ap.onclick = async function () {
hideMsg("msg");
ap.disabled = true; ap.textContent = "Approving…";
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");
load();
};
@ -133,8 +218,16 @@ function render(r) {
async function load() {
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 });
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);
}
load();