paytolaugh/script.html

397 lines
No EOL
22 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
.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}
.drop{border:1px dashed var(--line);border-radius:12px;padding:26px 20px;text-align:center;color:var(--muted);
background:#0f1117;cursor:pointer;font-size:14.5px}
.drop.hot{border-color:var(--accent);color:var(--ink)}
.pics{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:16px}
.pic img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block}
.pic input{margin-top:6px;font-size:13px;padding:7px 9px}
.pic .rm{background:none;border:0;color:var(--muted);cursor:pointer;font:11px/1.6 var(--mono);text-decoration:underline;padding:0}
.place{border-top:1px solid var(--line);padding:8px 0;font:12.5px/1.6 var(--mono);color:var(--muted)}
.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}
.tl{position:relative;height:56px;background:#0f1117;border:1px solid var(--line);border-radius:8px;
overflow:hidden;margin-top:12px;user-select:none;touch-action:none}
.tlcut{position:absolute;top:6px;bottom:6px;background:#1f6feb;border-radius:5px;cursor:grab;
font:11px/1.5 var(--mono);color:#fff;overflow:hidden;box-sizing:border-box;
display:flex;align-items:center;gap:6px;padding-right:11px}
.tlcut.moved{background:#8957e5}
.tlcut img,.tlcut video{height:100%;width:34px;object-fit:cover;flex:0 0 auto;display:block}
.tlcut b{font-weight:600;white-space:nowrap;overflow:hidden;padding-left:2px}
.tlshots{position:relative;height:46px;margin-top:14px}
.tlshot{position:absolute;top:0;bottom:0;border-left:1px solid var(--line);padding-left:4px;
font:10px/1.3 var(--mono);color:var(--muted);overflow:hidden;box-sizing:border-box}
.tlshot img{height:28px;width:auto;border-radius:3px;display:block;margin-top:2px;border:1px solid var(--line)}
.tlcut .grip{position:absolute;top:0;right:0;bottom:0;width:9px;cursor:ew-resize;background:rgba(255,255,255,.28)}
.tlhead{position:absolute;top:0;bottom:0;width:2px;background:var(--accent2);pointer-events:none}
.tlruler{position:absolute;left:0;right:0;bottom:0;height:14px;font:9px/14px var(--mono);color:var(--muted)}
.tlruler span{position:absolute;transform:translateX(-50%)}
.steps{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}
.steps button{background:#0f1117;border:1px solid var(--line);color:var(--muted);
border-radius:999px;padding:8px 15px;font:12px/1 var(--mono);cursor:pointer}
.steps button.done{color:var(--ink)}
.steps button.on{background:var(--accent);border-color:var(--accent);color:#fff;cursor:default}
.steps button[disabled]{opacity:.35;cursor:not-allowed}
.stage{position:relative;width:100%;max-width:300px}
.stage>video{width:100%;aspect-ratio:9/16;border-radius:10px;background:#000;display:block}
.ovl{position:absolute;inset:0;border-radius:10px;overflow:hidden;background:#000;display:none}
.ovl>*{width:100%;height:100%;object-fit:cover;display:block}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="title">The script</h1>
<p id="sub">Read it through, then direct it. Every shot can have its own setting and camera move.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div id="out"><p style="color:var(--muted)">Loading…</p></div>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("videos");
var email = requireSession();
var pid = new URLSearchParams(location.search).get("id");
var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
// Shot length is a property of the engine the project will be filmed on, not a
// constant. Veo shoots 8s, Omni 1.1 shoots 10s, so every duration and price
// derived from "segments" has to ask which engine first.
var MODELS = [];
function modelFor(p) {
var id = (p && p.render_model) || "";
for (var i = 0; i < MODELS.length; i++) if (MODELS[i].id === id) return MODELS[i];
return null;
}
function shotSecondsOf(p) { var m = modelFor(p); return (m && m.shot_seconds) || 8; }
function videoSecondsOf(p) { return ((p && p.segments) || 4) * shotSecondsOf(p); }
if (!pid) {
document.getElementById("out").innerHTML =
'<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</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 cont = s.source === "previous" && s.index > 1;
var toggle = s.index > 1
? '<div class="chips" data-srcgrp="' + s.index + '" style="margin-bottom:14px">'
+ '<span class="chip' + (cont ? '' : ' on') + '" data-src="photo">Start from a photo</span>'
+ '<span class="chip' + (cont ? ' on' : '') + '" data-src="previous">Continue from shot ' + (s.index - 1) + '</span>'
+ '</div>'
: '';
var settingLabel = cont
? '<label class="mini">The setting <span>— stays as it was; the camera is what moves</span></label>'
: '<label class="mini">The setting <span>— anywhere, including places a real pet could not be</span></label>';
var picker = cont
? '<p class="foot" style="margin:0">Continues the previous shot. Filming uses that shot&rsquo;s '
+ 'real last frame; the still here is a preview of the camera move.</p>'
: '<label class="mini">Which photo of them</label>'
+ '<select data-photo="' + s.index + '">' + photoOptions(s.photo_id) + '</select>';
// A cutaway belonging to this shot straddles the cut into the next one, so
// the registration jump happens off screen.
var br = s.broll || {};
var brollBlock = '';
if (br.subject) {
brollBlock = '<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--line)">'
+ '<label class="mini">Cutaway <span>— shown while this line is spoken, covering the cut</span></label>'
+ '<p class="foot" style="margin:0 0 8px">' + esc(br.subject) + ' · ' + esc(br.kind || 'image') + '</p>'
+ (s.broll_url
? (s.broll_kind === 'video'
? '<video src="' + esc(s.broll_url) + '" controls playsinline style="width:120px;border-radius:8px;border:1px solid var(--line)"></video>'
: '<img src="' + esc(s.broll_url) + '" alt="cutaway" style="width:120px;border-radius:8px;border:1px solid var(--line);display:block">')
: '')
+ (locked ? '' : '<button type="button" class="btn ghost" data-broll="' + s.index + '" style="margin-top:10px">'
+ (s.broll_url ? 'Remake cutaway' : 'Make cutaway') + '</button>')
+ '</div>';
}
var controls = locked ? '' :
toggle
+ settingLabel
+ '<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>' + picker + '</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 ? "Redraw the opening still" : "Draw the opening still") + '</button>'
+ '<span class="foot" style="display:block;margin-top:6px">One picture, not the video. '
+ 'It is drawn from their photo and is what carries their likeness.</span></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>'
+ brollBlock
+ '</div></div>';
}
// Four jobs, one at a time. Which one you are on follows from the project's
// status; VIEW lets you step back to look at an earlier one without pretending
// the work has been undone.
var STAGES = ["The words", "The layout", "Filming", "The cut"];
var VIEW = null;
function stageOf(st) {
if (st === "ready" || st === "film_failed") return 4;
if (st === "queued_to_film" || st === "filming") return 3;
if (st === "approved") return 2;
return 1;
}
function stepper(stage, view) {
return '<div class="steps">' + STAGES.map(function (n, k) {
var i = k + 1;
var cls = i === view ? "on" : (i <= stage ? "done" : "");
return '<button type="button" class="' + cls + '" data-stage="' + i + '"'
+ (i <= stage ? '' : ' disabled') + '>' + i + ' &middot; ' + esc(n) + '</button>';
}).join("") + '</div>';
}
// Stage one shows the writing and nothing else: no frames, no camera, no
// settings. Just what the pet actually says.
function linesHtml(s) {
return '<div class="shot" style="grid-template-columns:1fr;padding:16px 20px"><div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ '<p class="line" style="margin:8px 0 6px">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words &middot; about '
+ (s.est_seconds || 0) + 's</p></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 now. This usually takes under a minute — the page updates itself.</p></div>';
if (!poll) poll = setInterval(load, 5000);
return;
}
if (poll) { clearInterval(poll); poll = null; }
if (p.status === "script_failed") {
// The reason was always stored; it just was not being shown. A dead or
// paywalled link is the usual cause, and only the reader can fix it.
var why = (s && s.error) ? s.error : "";
out.innerHTML = '<div class="card"><p style="margin:0 0 10px">The writer could not finish this one.</p>'
+ (why ? '<div class="warn" style="margin:0 0 16px">' + esc(why) + '</div>'
: '<p class="foot" style="margin:0 0 16px">No reason was recorded.</p>')
+ '<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 yet.</div>'; return; }
document.getElementById("title").textContent = s.title || "The script";
document.getElementById("sub").textContent = s.premise || "";
var stage = stageOf(p.status);
if (VIEW === null || VIEW > stage) VIEW = stage;
// Shot controls belong to the layout stage. Elsewhere the shots are shown
// but not touchable, which is what "locked" means to shotHtml.
var locked = VIEW !== 2;
var canEditScript = (stage === 1);
var warn = (s._warnings && s._warnings.length)
? '<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">' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots'
+ (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '</div></div>'
+ '<div class="sub">' + (canEditScript
? left + " rewrite" + (left === 1 ? "" : "s") + " left"
: "Words approved") + '</div></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>';
// Post production only exists once there is footage to cut.
// The owner's own photographs, scoped to this video only. Described once,
// then matched to shots by reading the script rather than hand-assigned.
var own = locked ? '' :
'<h2 class="sec" style="margin-top:34px">Your own pictures</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Paste or drop photographs here. Describe each one, '
+ 'and they can be cut in where the script talks about them, or laid across a join to cover it.</p>'
+ '<div class="drop" id="drop" tabindex="0">Paste an image, drop one here, or click to choose</div>'
+ '<input type="file" id="picker" accept="image/*" multiple style="display:none">'
+ '<div class="pics" id="pics"></div>'
+ '<div style="margin-top:16px">'
+ '<button type="button" class="btn ghost" id="suggest">Suggest where they fit</button>'
+ '<span id="sugnote" class="foot" style="margin-left:12px"></span></div>'
+ '<div id="places"></div>'
+ '</div>';
// Filming, from the browser. This was the last step that needed an operator.
var film = '';
if (p.status === 'approved' || p.status === 'queued_to_film'
|| p.status === 'filming' || p.status === 'film_failed'
|| p.status === 'ready') {
var busy = (p.status === 'queued_to_film' || p.status === 'filming');
// The opening frame is what carries the animal's likeness into the video.
// Without one the model works from the written description and invents a
// plausible stranger, so say so before any money is spent.
var noFrame = (s.segments || []).filter(function (x) {
return x.source !== 'previous' && !x.plate_url;
}).map(function (x) { return x.index; });
film = '<h2 class="sec" style="margin-top:34px">Film it</h2><div class="card">'
+ (noFrame.length && !busy
? '<div class="warn">Shot' + (noFrame.length > 1 ? 's ' : ' ') + noFrame.join(', ')
+ ' still ' + (noFrame.length > 1 ? 'have' : 'has') + ' no opening frame. '
+ 'That frame is what carries your pet&rsquo;s actual likeness — their collar, '
+ 'their markings, their eyes — into the video. '
+ 'One will be drawn automatically before filming, which adds about half a minute '
+ 'per shot. Make them yourself above if you want to see them first.</div>'
: '')
+ '<div class="quote" style="margin-bottom:16px">'
+ '<div><div class="big">' + videoSecondsOf(p) + 's</div>'
+ '<div class="sub">of your time credit</div></div>'
+ '<div class="sub" id="balsec">checking your balance…</div></div>'
+ (p.status === 'film_failed'
? '<div class="warn">That did not finish: ' + esc(p.film_note || 'unknown reason')
+ '. Your seconds were returned.</div>'
: '')
+ (busy
? '<p class="foot" id="filmnote">' + esc(p.film_note || 'Queued…') + '</p>'
: '<button type="button" class="btn" id="film">'
+ (p.status === 'film_failed' ? 'Try filming again'
: (p.status === 'ready' ? 'Film it again' : 'Film it'))
+ '</button>'
+ '<span class="foot" style="margin-left:12px">About a minute a shot. '
+ 'Anything still missing — an opening still, a cutaway — is made '
+ 'automatically first, so nothing above is required.</span>'
+ (p.status === 'ready'
? '<p class="foot" style="margin:10px 0 0">This has already been filmed. '
+ 'Filming again spends the seconds again and replaces the video.</p>'
: ''))
+ '</div>';
}
var post = '';
if (p.status === 'ready' || p.cut_url) {
post = '<h2 class="sec" style="margin-top:34px">The cut</h2><div class="card">'
+ (p.cut_url
? '<video src="' + esc(p.cut_url) + '" controls playsinline style="width:190px;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);background:#000;display:block;margin-bottom:14px"></video>'
: '')
+ '<p class="foot" style="margin:0 0 12px">Cutaways are laid across the joins between shots, '
+ 'so the picture changes while the voice keeps going and the join is never on screen.</p>'
+ '<button type="button" class="btn ghost" id="edlopen" style="margin-bottom:16px">Open the video editor</button>'
+ '<div id="edlwrap" style="margin:6px 0 18px;display:none">'
+ '<div class="stage" style="margin-bottom:12px">'
+ '<video id="pvid" playsinline preload="metadata"></video>'
+ '<div class="ovl" id="povl"><img id="povlimg" alt="cutaway"><video id="povlvid" muted playsinline></video></div>'
+ '</div>'
+ '<button type="button" class="btn ghost" id="pplay">&#9654; Preview the cut</button>'
+ '<span class="foot" style="margin-left:12px" id="ptime">0.0 / 0.0s</span>'
+ '<span class="foot" style="margin-left:12px">Nothing is rendered &mdash; this switches the picture as it plays.</span>'
+ '<div class="tlshots" id="tlshots"></div>'
+ '<div class="tl" id="tl"></div>'
+ '<p class="foot" id="tlinfo" style="margin:8px 0 0"></p>'
+ '<div style="margin-top:12px">'
+ '<button type="button" class="btn ghost" id="edlsave" disabled>Save these positions</button>'
+ '<button type="button" class="btn ghost" id="edlreset" style="margin-left:8px">Back to the suggested cut</button>'
+ '</div></div>'
+ '<button type="button" class="btn ghost" id="seams">Measure the joins</button>'
+ '<button type="button" class="btn" id="cut" style="margin-left:10px">'
+ (p.cut_url ? 'Cut it again' : 'Cut the B-roll in') + '</button>'
+ '<div id="seamout" style="margin-top:14px"></div>'
+ '<div id="takes" style="margin-top:20px"></div>'
+ '</div>'
+ '<h2 class="sec" style="margin-top:34px">Send it to someone</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Texting the video itself puts it through '
+ 'carrier transcoding and it arrives looking worse than it left. Send the picture as '
+ 'the hook and let the link carry the video at full quality.</p>'
+ '<button type="button" class="btn ghost" id="sharebtn">Make the picture</button>'
+ '<span class="foot" style="margin-left:12px" id="sharenote"></span>'
+ '<div id="shareout" style="margin-top:16px"></div>'
+ '</div>';
}
var actions = !canEditScript
? '<div class="card"><p style="margin:0"><strong>These words are settled.</strong> '
+ 'Everything after this arranges how they are shot, so the writing is left alone.</p></div>'
: '<div class="card">'
+ '<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 the words" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</button>'
+ '</div>';
var body;
if (VIEW === 1) {
body = head + warn + full
+ '<h2 class="sec">The words, shot by shot</h2>'
+ s.segments.map(linesHtml).join("")
+ actions;
} else if (VIEW === 2) {
body = head
+ '<p class="note" style="margin:0 0 22px"><strong>How it will be laid out.</strong> '
+ 'Each shot has a setting, a camera move and an opening frame that carries your '
+ 'pet&rsquo;s likeness. Cutaways sit across the joins so the cut is never on screen. '
+ 'All of it has been chosen for you &mdash; change anything you disagree with, or '
+ 'go straight to filming.</p>'
+ '<h2 class="sec">Hear it first</h2>'
+ '<div class="card" id="pzcard">'
+ '<p class="foot" style="margin:0 0 14px">Filming costs real money; speaking the '<!--PTLCHUNK-->